Repository navigation
[io] Write rowspan / colspan for merged table cells in the HTML export - #2226
Conversation
HTMLExporter.render_table built the table from Table.to_grid(), so a cell spanning several rows or columns was written as a plain cell followed by empty ones and the merge predicted by the table model was lost. A merged cell is now written with rowspan / colspan and the positions it covers are skipped, unless that would hide the text of another cell or cross another merged cell (the dense layout is then kept). The other formats are unchanged.
felixdittrich92
left a comment
There was a problem hiding this comment.
Thanks @sibisrinivasb , this is a nice improvement. Only writing a merge when every covered position is empty is the right default for OCR output, since no recognized text can be lost. A few things before merging:
1. Make the result independent of the order of table.cells (blocking)
The span loop goes through table.cells in the order they're stored, but to_grid() sorts the cells by reading order. When two merged cells overlap, whichever comes first in the list wins, so the same table can export to different HTML. For example, two empty merges over rows 0–1 × columns 0–1 and over rows 1–2 × columns 1–2 give two different outputs depending on list order.
Sorting first makes it deterministic. The merge whose top-left comes first in row-major order wins:
# The merge whose top-left comes first (row-major) wins, whatever the order of `table.cells`
for cell in sorted(table.cells, key=lambda cell: (cell.row_start, cell.col_start)):2. Don't write empty <tr></tr> rows (blocking)
If every column of a row is covered by merges from above, the row is written with no cells:
<tr><th rowspan="2">A</th><th rowspan="2">B</th></tr>
<tr></tr>
<tr><td>x</td><td>y</td></tr>It's valid HTML, but some HTML -> DataFrame/Markdown converters handle it badly, and the layout is the same without it. One way to fix it: drop those rows and shrink the row spans that cross them. Put this after the span loop:
# Drop the rows entirely covered by merges from above (no `<tr></tr>`), shrinking the row spans crossing them
dropped = {r for r in range(num_rows) if all((r, c) in covered for c in range(num_cols))}
if dropped:
spans = {
(r0, c0): (row_span - sum(r in dropped for r in range(r0, r0 + row_span)), col_span)
for (r0, c0), (row_span, col_span) in spans.items()
}and skip them when building the rows:
rows = [
"<tr>"
+ "".join(_cell(value, "td" if r else "th", (r, c)) for c, value in enumerate(row) if (r, c) not in covered)
+ "</tr>"
for r, row in enumerate(grid)
if r not in dropped
](If you'd rather keep the empty rows on purpose, that's fine too, but then please add a test that pins that output down.)
3. Tests for the untested branches
None of the current tests exercise area & covered or any(pos in spans for pos in area). Please add these to test_table_html_export_spans:
# Crossing merges: the first one in row-major order wins, whatever the order of the cells
first, second, corner = ("", 0, 1, 0, 1), ("", 1, 2, 1, 2), ("z", 0, 0, 2, 2)
expected = (
'<table>\n<tr><th rowspan="2" colspan="2"></th><th>z</th></tr>\n'
"<tr><td></td></tr>\n<tr><td></td><td></td><td></td></tr>\n</table>"
)
assert HTMLExporter().render_table(_table([first, second, corner], 3, 3)) == expected
assert HTMLExporter().render_table(_table([second, first, corner], 3, 3)) == expected
# A row covered entirely by merges from above is dropped and the row spans crossing it shrink
full = _table([("A", 0, 1, 0, 0), ("B", 0, 1, 1, 1), ("x", 2, 2, 0, 0), ("y", 2, 2, 1, 1)], 3, 2)
assert HTMLExporter().render_table(full) == (
"<table>\n<tr><th>A</th><th>B</th></tr>\n<tr><td>x</td><td>y</td></tr>\n</table>"
)
tall = _table([("L", 0, 2, 0, 0), ("R", 0, 1, 1, 1), ("r2", 2, 2, 1, 1)], 3, 2)
assert HTMLExporter().render_table(tall) == (
'<table>\n<tr><th rowspan="2">L</th><th>R</th></tr>\n<tr><td>r2</td></tr>\n</table>'
)Nits
- Whitespace check: a covered position that holds only whitespace currently blocks the merge, even though
_cellstrips it to an empty cell anyway. To be consistent with_cell:if area & covered or any(pos in spans for pos in area) or any(grid[r][c].strip() for r, c in others):
- Docs:
docs/source/user_guide/exporting.mdonly says tables are "rendered as real tables". It's worth adding that HTML now keeps merged cells, e.g.:- **With table recognition** (`detect_tables=True`), tables are rendered as real tables. The HTML export keeps merged cells (`rowspan` / `colspan`); the other formats place the text of a merged cell at its top-left position.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2226 +/- ##
==========================================
+ Coverage 98.16% 98.17% +0.01%
==========================================
Files 172 172
Lines 11977 11995 +18
==========================================
+ Hits 11757 11776 +19
+ Misses 220 219 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks for the review! All applied as suggested:
One addition, in a separate commit: two merges sharing the same top-left still resolved by list order, since the sort key was equal. I added |
What
HTMLExporter.render_tablenow writes a cell that spans several rows or columns withrowspan/colspanand skips the positions it covers.Why
The table model returns merged cells (
TableCell.row_span/col_span), but the HTML export was built fromTable.to_grid(), so a merged cell came out as a plain cell followed by empty ones and the merge was lost in the one format that can express it. For this table:before:
after:
End to end,
ocr_predictor(pretrained=True, detect_tables=True)on a ruled table with a header cell over two columns returns that cell withcol_span == 2; the HTML export wrote<th>Revenue</th><th></th>and now writes<th colspan="2">Revenue</th>.Behaviour
Table.to_grid()and the Markdown / AsciiDoc / text exports are unchanged (Markdown cannot express merged cells).Tests
test_table_html_export_spans: a row merge and a column merge, a merge that would hide another cell's text (kept dense), a span clipped to the grid. It fails on main and passes here; the other tests oftests/common/test_io_exporters.pypass unchanged.AsciiDoc can express merged cells too (
2+|,.2+|); I left it out to keep this small and can add it if you want it.