Skip to content

Commit 93b77bc

Browse files
scottf007claude
andcommitted
fix(serializer): collapse multi-row column headers into one markdown header
MarkdownTableSerializer hardcoded `headers=rows[0]` and `rows[1:]` as the body. When TableFormer (correctly) marks multiple leading rows as column_header=True — the case where a column title wraps onto two visual lines like "Cash per Security" + "($)" — only the first row rendered as the markdown header and the continuation leaked into the body as a spurious "data" row. Mirrors the logic that already exists in `_export_to_dataframe_with_options` (document.py:2219-2245): count leading grid rows containing any column_header cell, concatenate their cell text per column to build the markdown header, and use the remaining grid rows as the body. Spanning siblings render empty in both cases so a colspan=N header is not concatenated N times. Falls back to "first row is header" if no row has any column_header cell, preserving prior behaviour for tables that arrive without header marking. Empirical result on a 10-PDF dividend-statement corpus (finance_nexus tests/fixtures/dividend_statement/): * IOZ_Reinvestment_Plan_Advice_2025_04_17.pdf: the wrapped header collapses from two rows into one. Body unchanged. * All 9 other fixtures: byte-identical markdown output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 275bd1d commit 93b77bc

1 file changed

Lines changed: 40 additions & 5 deletions

File tree

docling_core/transforms/serializer/markdown.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -549,18 +549,53 @@ def serialize(
549549
rendered_row.append(cell_text.replace("\n", " ").replace("|", "&#124;"))
550550
rows.append(rendered_row)
551551
if len(rows) > 0:
552+
# Count leading rows that contain any column_header cell so
553+
# multi-line wrapped headers ("Cash per Security" + "($)" on
554+
# successive grid rows) collapse into one markdown header
555+
# row instead of leaking the continuation into the body.
556+
# Mirrors `_export_to_dataframe_with_options` in
557+
# docling_core/types/doc/document.py:2219-2245. Falls back to
558+
# "first row is header" if upstream did not mark any header
559+
# cells, preserving prior behaviour for that case.
560+
num_header_rows = 0
561+
for grid_row in item.data.grid:
562+
if any(
563+
getattr(cell, "column_header", False) for cell in grid_row
564+
):
565+
num_header_rows += 1
566+
else:
567+
break
568+
if num_header_rows == 0:
569+
num_header_rows = 1
570+
571+
num_cols = max(len(r) for r in rows)
572+
if num_header_rows == 1:
573+
headers = rows[0]
574+
else:
575+
# Concatenate header lines per column with a single
576+
# space. Empty cells (spanning siblings, gaps) skipped
577+
# so a colspan=N header isn't doubled.
578+
headers = []
579+
for col_idx in range(num_cols):
580+
parts = [
581+
rows[r][col_idx]
582+
for r in range(min(num_header_rows, len(rows)))
583+
if col_idx < len(rows[r]) and rows[r][col_idx]
584+
]
585+
headers.append(" ".join(parts))
586+
body = rows[num_header_rows:]
587+
552588
# Always disable numparse to prevent silent precision loss in numeric values
553589
# Use tabulate's _column_type to detect numeric columns for right-alignment
554590
colalign = []
555-
if len(rows) > 1: # Need at least header + 1 data row
556-
num_cols = len(rows[0])
591+
if body: # Need at least one data row to detect column types
557592
for col_idx in range(num_cols):
558-
col_values = [row[col_idx] if col_idx < len(row) else "" for row in rows[1:]]
593+
col_values = [row[col_idx] if col_idx < len(row) else "" for row in body]
559594
col_type = _column_type(col_values)
560595
colalign.append("right" if col_type in (int, float) else "left")
561596
table_text = tabulate(
562-
rows[1:],
563-
headers=rows[0],
597+
body,
598+
headers=headers,
564599
tablefmt="github",
565600
disable_numparse=True,
566601
colalign=tuple(colalign) if colalign else None,

0 commit comments

Comments
 (0)