Skip to content

Commit a2fb4fb

Browse files
kingyzhangclaude
andcommitted
fix(excel_import): smart header detection + ArrayFormula + summary rows
Found via /data/docx-online/测试1.xlsx — a 21×15 sheet of pure numeric data (`1,1,...` to `21,21,...`) with a trailing `=SUM(A1:I26)` in A27 and a stray ArrayFormula at A1. The import produced a near-unrecognizable result. Three independent issues compounded: 1. **Numeric-only first row consumed as header.** _extract_sheet_data unconditionally promoted row 1 to column names, so a sheet without a header row silently lost its first data row, and pandas helpfully renamed the duplicate "1, 1, 1, 1" columns to "1, 1.1, 1.2, 1.3" making the table look corrupt. Fix: _looks_like_header_row() — a row is a header iff it has at least one cell AND every non-empty cell is a non-numeric, non-formula string. A row of all numbers (or numeric strings) is data; we synthesize col_A, col_B, ... headers instead. 2. **ArrayFormula objects silently dropped.** _extract_formulas guarded on `isinstance(cell.value, str) and startswith('=')`, so openpyxl's ArrayFormula objects (dynamic-array / cross-workbook references) were skipped without trace. Fix: detect objects with a .text attribute and route them through the same pipeline, prepending '=' if needed. 3. **Trailing summary rows mixed into data.** The cached value of `=SUM(A1:I26)` (2082) appeared as a one-cell row in the data CSV. Looks like a data anomaly, isn't. Fix: _is_summary_row() detects trailing rows with at most one non-empty cell where that cell is a formula in the formula-mode workbook. Such rows are pulled out of the data CSV and emitted as labelled `# Cell A27: =SUM(A1:I26) (cached value: 2082)` comments in the compute section, where they belong. Result on 测试1.xlsx now: data: 21 rows × 15 cols, col_A=1..21 (the data the user wrote) compute: # Cell A27: =SUM(A1:I26) (cached value: 2082) # Excel: =[1]Sheet1!$A$1:$O$21 ← previously dropped 4 new regression tests in tests/test_io.py:TestExcelImport cover each fix in isolation plus an ArrayFormula round-trip. 22/22 io tests pass, full suite 450/450. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d0136df commit a2fb4fb

2 files changed

Lines changed: 327 additions & 50 deletions

File tree

gridlang/excel_import.py

Lines changed: 218 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def import_excel(
8787
# --- data (per sheet) ---
8888
all_formulas = {} # sheet_name → list of formula info
8989
all_styles = {} # sheet_name → style info
90+
all_summaries = {} # sheet_name → list of {coord, formula, value}
9091

9192
for sheet_name in target_sheets:
9293
ws_data = wb_data[sheet_name]
@@ -99,10 +100,13 @@ def import_excel(
99100
safe_name = _safe_sheet_name(sheet_name)
100101
parts.append(f"--- data:{safe_name} ---")
101102

102-
csv_content = _extract_sheet_data(ws_data)
103+
csv_content, summary_rows = _extract_sheet_data(ws_data, ws_formula)
103104
parts.append(csv_content)
104105
parts.append("")
105106

107+
if summary_rows:
108+
all_summaries[sheet_name] = summary_rows
109+
106110
# Collect formulas
107111
if ws_formula and include_formulas:
108112
formulas = _extract_formulas(ws_formula)
@@ -117,7 +121,7 @@ def import_excel(
117121

118122
# --- compute ---
119123
parts.append("--- compute ---")
120-
compute_code = _generate_compute_section(all_formulas, target_sheets)
124+
compute_code = _generate_compute_section(all_formulas, target_sheets, all_summaries)
121125
parts.append(compute_code)
122126
parts.append("")
123127

@@ -149,13 +153,144 @@ def import_excel_to_file(
149153
# Data Extraction
150154
# =============================================================================
151155

152-
def _extract_sheet_data(ws) -> str:
153-
"""Extract sheet data as CSV string."""
154-
rows = []
155-
for row in ws.iter_rows(values_only=True):
156-
# Skip completely empty rows
157-
if all(cell is None for cell in row):
156+
def _looks_like_header_row(row: tuple) -> bool:
157+
"""
158+
Decide whether a row of cells looks like a column-header row.
159+
160+
True iff the row has at least one cell AND every non-empty cell is a
161+
string that's either:
162+
- non-numeric (e.g. "Region", "Q1 Sales", "%-of-total")
163+
- or short and obviously a label (no purely-numeric strings either).
164+
165+
A row of all-numeric values (`1, 1, 1, ...` or `2024, 2025, ...`) is
166+
NOT a header — it's data.
167+
168+
A mixed row ("Region", 100, 200, ...) is NOT a header either, because
169+
real headers don't mix labels and numbers in one row.
170+
"""
171+
non_empty = [c for c in row if c is not None and str(c).strip() != ""]
172+
if not non_empty:
173+
return False # empty rows are never headers
174+
175+
for c in non_empty:
176+
# Numbers (int, float, bool) → not a header cell.
177+
if isinstance(c, (int, float, bool)) and not isinstance(c, str):
178+
return False
179+
# Strings that parse as numbers → not header cells.
180+
if isinstance(c, str):
181+
s = c.strip()
182+
try:
183+
float(s)
184+
return False # purely numeric string
185+
except (ValueError, TypeError):
186+
pass
187+
if s.startswith("="):
188+
return False # formula text — not a header
189+
else:
190+
# datetime, etc. — not a header
191+
return False
192+
return True
193+
194+
195+
def _is_summary_row(row: tuple, formula_row: Optional[tuple] = None) -> bool:
196+
"""
197+
Decide whether a row looks like a single-cell summary row at the bottom
198+
of a data block (e.g. just `=SUM(A1:I26)` in column A, rest empty).
199+
200+
Heuristic: at most one non-empty cell, AND either that cell is a formula
201+
in `formula_row`, or the cached value in `row` is a number while every
202+
other cell is empty.
203+
"""
204+
non_empty_idx = [i for i, c in enumerate(row) if c is not None and str(c).strip() != ""]
205+
if len(non_empty_idx) > 1:
206+
return False # multiple values → looks like real data
207+
if not non_empty_idx:
208+
return False # empty row, caller filters those separately
209+
# Exactly one value. If we have the formula-mode row, check it's a formula.
210+
if formula_row is not None:
211+
f = formula_row[non_empty_idx[0]]
212+
if isinstance(f, str) and f.startswith("="):
213+
return True
214+
# ArrayFormula objects also count as formulas.
215+
if f is not None and not isinstance(f, (int, float, bool, str)):
216+
return True
217+
return False
218+
219+
220+
def _extract_sheet_data(ws, ws_formula=None) -> tuple[str, list[dict]]:
221+
"""Extract sheet data as CSV string. Returns (csv, summary_rows).
222+
223+
`summary_rows` is a list of {'coord', 'formula', 'value'} dicts for any
224+
formula-only trailing rows that were filtered out of the data CSV.
225+
"""
226+
# Collect all rows up front so we can decide:
227+
# 1) whether row 1 is a header
228+
# 2) whether trailing rows are summary-only
229+
raw_rows = list(ws.iter_rows(values_only=True))
230+
formula_rows = (
231+
list(ws_formula.iter_rows(values_only=True))
232+
if ws_formula is not None
233+
else [None] * len(raw_rows)
234+
)
235+
236+
# ── 1. Drop trailing summary-only rows (e.g. `=SUM(A1:I26)` in col A).
237+
summary_rows = []
238+
end = len(raw_rows)
239+
while end > 0:
240+
# Skip blank rows from the bottom.
241+
cur = raw_rows[end - 1]
242+
if all(c is None or str(c).strip() == "" for c in cur):
243+
end -= 1
158244
continue
245+
# Is this a single-cell summary row?
246+
f_row = formula_rows[end - 1] if end - 1 < len(formula_rows) else None
247+
if _is_summary_row(cur, f_row):
248+
non_empty_idx = [i for i, c in enumerate(cur) if c is not None and str(c).strip() != ""]
249+
i = non_empty_idx[0]
250+
coord = f"{get_column_letter(i + 1)}{end}"
251+
f_val = f_row[i] if f_row is not None else None
252+
# Normalize ArrayFormula → its formula text.
253+
if hasattr(f_val, "text"):
254+
f_text = f_val.text
255+
elif isinstance(f_val, str) and f_val.startswith("="):
256+
f_text = f_val
257+
else:
258+
f_text = None
259+
summary_rows.append({
260+
"coord": coord,
261+
"formula": f_text,
262+
"value": cur[i],
263+
})
264+
end -= 1
265+
continue
266+
break # found a real data row, stop trimming
267+
268+
summary_rows.reverse() # restore top-to-bottom order
269+
270+
# ── 2. Drop trailing blank rows that came BEFORE the now-last data row,
271+
# AND any rows we already trimmed.
272+
rows_in = raw_rows[:end]
273+
# Filter blank rows everywhere (preserves prior behaviour for the body).
274+
body_rows = [r for r in rows_in if not all(c is None for c in r)]
275+
276+
if not body_rows:
277+
return "", summary_rows
278+
279+
# ── 3. Decide whether row 1 is a header or data.
280+
has_header = _looks_like_header_row(body_rows[0])
281+
if has_header:
282+
header_row = body_rows[0]
283+
data_rows = body_rows[1:]
284+
headers = [_clean_column_name(str(c) if c is not None else "") for c in header_row]
285+
else:
286+
# Synthesize col_A, col_B, ... headers from column count.
287+
n_cols = max(len(r) for r in body_rows)
288+
headers = [f"col_{get_column_letter(i + 1)}" for i in range(n_cols)]
289+
data_rows = body_rows
290+
291+
# ── 4. Serialize.
292+
csv_lines = [",".join(headers)]
293+
for row in data_rows:
159294
csv_row = []
160295
for cell in row:
161296
if cell is None:
@@ -166,22 +301,12 @@ def _extract_sheet_data(ws) -> str:
166301
csv_row.append(str(int(cell)))
167302
else:
168303
val = str(cell)
169-
# Quote if contains comma or quotes
170-
if ',' in val or '"' in val or '\n' in val:
304+
if "," in val or '"' in val or "\n" in val:
171305
val = '"' + val.replace('"', '""') + '"'
172306
csv_row.append(val)
173-
rows.append(','.join(csv_row))
174-
175-
if not rows:
176-
return ""
307+
csv_lines.append(",".join(csv_row))
177308

178-
# Clean column headers (make valid Python identifiers)
179-
if rows:
180-
headers = rows[0].split(',')
181-
clean_headers = [_clean_column_name(h) for h in headers]
182-
rows[0] = ','.join(clean_headers)
183-
184-
return '\n'.join(rows)
309+
return "\n".join(csv_lines), summary_rows
185310

186311

187312
def _clean_column_name(name: str) -> str:
@@ -219,18 +344,35 @@ def _clean_column_name(name: str) -> str:
219344

220345

221346
def _extract_formulas(ws) -> list[dict]:
222-
"""Extract formulas from worksheet."""
347+
"""Extract formulas from worksheet.
348+
349+
Handles both plain `=SUM(...)` strings and openpyxl ArrayFormula
350+
objects (dynamic-array formulas). The latter would otherwise be
351+
silently dropped because their `.value` is not a string.
352+
"""
223353
formulas = []
224354
for row in ws.iter_rows():
225355
for cell in row:
226-
if cell.value and isinstance(cell.value, str) and cell.value.startswith('='):
227-
formulas.append({
228-
'cell': cell.coordinate,
229-
'column': get_column_letter(cell.column),
230-
'col_idx': cell.column - 1,
231-
'row': cell.row,
232-
'formula': cell.value,
233-
})
356+
v = cell.value
357+
if v is None:
358+
continue
359+
# Plain text formula.
360+
if isinstance(v, str) and v.startswith('='):
361+
formula_text = v
362+
# openpyxl ArrayFormula object — has a .text attribute.
363+
elif hasattr(v, 'text') and isinstance(getattr(v, 'text', None), str):
364+
formula_text = v.text
365+
if not formula_text.startswith('='):
366+
formula_text = '=' + formula_text
367+
else:
368+
continue
369+
formulas.append({
370+
'cell': cell.coordinate,
371+
'column': get_column_letter(cell.column),
372+
'col_idx': cell.column - 1,
373+
'row': cell.row,
374+
'formula': formula_text,
375+
})
234376
return formulas
235377

236378

@@ -470,36 +612,62 @@ def _convert_range_to_col(range_str: str, headers: list[str] = None) -> str:
470612
return range_str
471613

472614

473-
def _generate_compute_section(all_formulas: dict, sheet_names: list[str]) -> str:
615+
def _generate_compute_section(
616+
all_formulas: dict,
617+
sheet_names: list[str],
618+
all_summaries: Optional[dict] = None,
619+
) -> str:
474620
"""Generate compute section from extracted formulas."""
475621
lines = []
476622
lines.append("def transform(df):")
477623

478624
has_formulas = any(formulas for formulas in all_formulas.values())
625+
has_summaries = bool(all_summaries) and any(s for s in (all_summaries or {}).values())
479626

480-
if has_formulas:
627+
if has_formulas or has_summaries:
481628
lines.append(" # Auto-converted from Excel formulas")
482629
lines.append(" # Review and adjust as needed")
483630
lines.append("")
484631

485-
for sheet_name, formulas in all_formulas.items():
486-
if len(all_formulas) > 1:
487-
lines.append(f" # --- Sheet: {sheet_name} ---")
488-
489-
# Group formulas by type
490-
seen_patterns = set()
491-
for f_info in formulas:
492-
python_code = _convert_formula_to_python(f_info['formula'])
493-
# Avoid duplicates (same formula applied to many rows)
494-
pattern_key = re.sub(r'\d+', 'N', f_info['formula'])
495-
if pattern_key not in seen_patterns:
496-
seen_patterns.add(pattern_key)
497-
if python_code.startswith('#'):
498-
lines.append(f" {python_code}")
499-
else:
500-
lines.append(f" # Cell {f_info['cell']}: {f_info['formula']}")
501-
lines.append(f" # → {python_code}")
502-
lines.append("")
632+
# Summaries (trailing single-cell formula rows) come first as a clearly
633+
# labelled block, since they were extracted out of the data table.
634+
if has_summaries:
635+
for sheet_name, summaries in all_summaries.items():
636+
if not summaries:
637+
continue
638+
if len(all_summaries) > 1:
639+
lines.append(f" # --- Summary cells for sheet: {sheet_name} ---")
640+
else:
641+
lines.append(" # --- Summary cells (trailing rows below data) ---")
642+
for s in summaries:
643+
coord = s.get("coord", "?")
644+
formula = s.get("formula") or "(array formula)"
645+
value = s.get("value")
646+
py_hint = _convert_formula_to_python(formula) if formula else ""
647+
lines.append(f" # Cell {coord}: {formula} (cached value: {value})")
648+
if py_hint and not py_hint.startswith("#"):
649+
lines.append(f" # → {py_hint}")
650+
lines.append("")
651+
652+
if has_formulas:
653+
for sheet_name, formulas in all_formulas.items():
654+
if len(all_formulas) > 1:
655+
lines.append(f" # --- Sheet: {sheet_name} ---")
656+
657+
# Group formulas by type
658+
seen_patterns = set()
659+
for f_info in formulas:
660+
python_code = _convert_formula_to_python(f_info['formula'])
661+
# Avoid duplicates (same formula applied to many rows)
662+
pattern_key = re.sub(r'\d+', 'N', f_info['formula'])
663+
if pattern_key not in seen_patterns:
664+
seen_patterns.add(pattern_key)
665+
if python_code.startswith('#'):
666+
lines.append(f" {python_code}")
667+
else:
668+
lines.append(f" # Cell {f_info['cell']}: {f_info['formula']}")
669+
lines.append(f" # → {python_code}")
670+
lines.append("")
503671
else:
504672
lines.append(" # No formulas detected — add your transformations here")
505673
lines.append(" pass")

0 commit comments

Comments
 (0)