Skip to content

fix(tables): keep th row headers and carry rowspan down - #2268

Open
L4XB wants to merge 1 commit into
unclecode:mainfrom
L4XB:fix/2258-th-rowspan-alignment
Open

L4XB wants to merge 1 commit into
unclecode:mainfrom
L4XB:fix/2258-th-rowspan-alignment

Conversation

@L4XB

@L4XB L4XB commented Sep 14, 2026

Copy link
Copy Markdown

The bug

DefaultTableExtraction.extract_table_data read only .//td for body rows, so a <th scope="row"> was dropped — the remaining cells shifted one place left and the row was padded with "" on the right. rowspan was not handled at all, so the rows a spanning cell covers shifted left too.

Run against the issue's own markup, before:

--- th row headers
  headers: ['', 'Feature A', 'Feature B']
  row:     ['yes', 'yes', '']          <- 'Item 1' is gone
  row:     ['no', 'yes', '']           <- 'Item 2' is gone
--- rowspan
  headers: ['Group', 'Option X', 'Option Y']
  row:     ['G1', 'x1', 'y1']
  row:     ['x2', 'y2', '']            <- shifted under 'Group'

after:

--- th row headers
  row:     ['Item 1', 'yes', 'yes']
  row:     ['Item 2', 'no', 'yes']
--- rowspan
  row:     ['G1', 'x1', 'y1']
  row:     ['G1', 'x2', 'y2']
--- plain (control)
  headers: ['A', 'B']
  row:     ['1', '2']
  row:     ['3', '4']                  <- unchanged

For a documentation table whose first column is a product or parameter name, that column disappeared from result.tables entirely. This is the follow-up to #2007: since v0.9.1 the spans survive in cleaned_html, and the extractor now uses them.

Two things the change had to handle that the issue does not mention

The header row becomes a body row. Without a thead the first row supplies the headers, and it is also in .//tr[not(ancestor::thead)]. It used to be skipped by accident — it holds no td, so row_data came out empty. Now that th counts, it has to be skipped on purpose, by identity against the row the headers came from. test_the_header_row_is_not_repeated_as_data pins that, and it fails if the skip is removed.

A span attribute is whatever the document said. int(cell.get("colspan", 1)) raises ValueError on colspan="nonsense" — a cell my own test wrote before I noticed — and colspan="0" would have dropped the cell and shifted the row, which is the same misalignment this change exists to fix. Both spans are now read through one helper that treats absent, empty, zero, negative and non-numeric alike as "no span", which is what a browser does.

Tests

python -m pytest tests/test_table_extraction_alignment.py
16 passed

tests/test_table_gfm_compliance.py (the neighbouring table tests): 13 passed, unchanged.

Against the extractor on main, with the new file in place, 5 fail — the two shapes from the issue and three span combinations:

FAILED test_a_th_row_header_stays_in_the_first_column
FAILED test_a_rowspan_cell_fills_the_rows_it_covers
FAILED test_a_rowspan_runs_out_after_the_rows_it_declared
FAILED test_a_rowspan_in_a_later_column_lands_in_that_column
FAILED test_a_cell_with_both_spans_covers_the_whole_block

Each cell was checked against a mutant that breaks the thing it names. Baseline 16/16 green, source restored and re-verified; every mutation asserts it changed the file first:

mutation result
read only td in the body (the behaviour on main) 1 failed / 15 passed
never carry a rowspan down 4 failed / 12 passed
carry a rowspan one row too far 1 failed / 15 passed
stop skipping the header row 2 failed / 14 passed
let a bad span be 0 instead of 1 2 failed / 14 passed
stop repeating a colspan 2 failed / 14 passed
(restored) 16 passed

The fifth row is why there is a colspan version of the bad-span cell as well as a rowspan one. With only the rowspan cases that mutant survived: rowspan of 0 and of 1 both mean "no carry", so nothing observable changed. It takes a zero colspan to see it, and that one drops a cell.

The control cell (test_a_plain_table_is_unchanged) asserts headers, rows and the metadata counts for an ordinary table, so a change that fixed the spans by breaking the common case would not pass.

Fixes #2258

`extract_table_data` read only `.//td` for body rows, so a
`<th scope="row">` was dropped: every other cell shifted one place left and
the row was padded with "" on the right. For a documentation table whose
first column is the product or parameter name, that column disappeared from
`result.tables` altogether.

`rowspan` was not handled at all, so the rows a spanning cell covers shifted
left by one and stopped lining up with the header. The value is now carried
into each row the cell declares.

Including `th` means the first row is a body row too when there is no
`thead`. It was skipped before only because it held no `td`, so it is now
skipped by identity against the row the headers came from.

`colspan` and `rowspan` come from the document and can be absent, empty,
zero, negative or not a number. `int()` raised on the last of those and zero
would have dropped the cell, so both are read through one helper that means
"no span" for all of them, the way a browser does.

Fixes unclecode#2258
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: DefaultTableExtraction drops <th> row headers and ignores rowspan, producing misaligned result.tables

1 participant