Skip to content

[io] Write rowspan / colspan for merged table cells in the HTML export - #2226

Merged
felixdittrich92 merged 3 commits into
mindee:mainfrom
sibisrinivasb:html-table-spans
Nov 13, 2026
Merged

felixdittrich92 merged 3 commits into
mindee:mainfrom
sibisrinivasb:html-table-spans

Conversation

@sibisrinivasb

Copy link
Copy Markdown
Contributor

What

HTMLExporter.render_table now writes a cell that spans several rows or columns with rowspan / colspan and skips the positions it covers.

Why

The table model returns merged cells (TableCell.row_span / col_span), but the HTML export was built from Table.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:

| Item (2 rows) | Revenue (2 columns) |
|               | 2023     | 2024     |
| Widgets       | 10       | 12       |

before:

<table>
<tr><th>Item</th><th>Revenue</th><th></th></tr>
<tr><td></td><td>2023</td><td>2024</td></tr>
<tr><td>Widgets</td><td>10</td><td>12</td></tr>
</table>

after:

<table>
<tr><th rowspan="2">Item</th><th colspan="2">Revenue</th></tr>
<tr><td>2023</td><td>2024</td></tr>
<tr><td>Widgets</td><td>10</td><td>12</td></tr>
</table>

End to end, ocr_predictor(pretrained=True, detect_tables=True) on a ruled table with a header cell over two columns returns that cell with col_span == 2; the HTML export wrote <th>Revenue</th><th></th> and now writes <th colspan="2">Revenue</th>.

Behaviour

  • A merge is written only when the positions it covers hold no other text and no other merged cell; otherwise the dense layout is kept, so no text is lost and the HTML stays valid.
  • A span reaching outside the grid is clipped to it.
  • Tables without merged cells give exactly the same HTML as before.
  • 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 of tests/common/test_io_exporters.py pass 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.

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 felixdittrich92 self-assigned this Nov 13, 2026
@felixdittrich92 felixdittrich92 added type: enhancement Improvement module: io Related to doctr.io ext: tests Related to tests folder topic: table comprehension Related to table comprehension labels Nov 13, 2026
@felixdittrich92 felixdittrich92 added this to the 1.2.0 milestone Nov 13, 2026

@felixdittrich92 felixdittrich92 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 _cell strips 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.md only 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

codecov Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.17%. Comparing base (dbcee0d) to head (4114ef2).
⚠️ Report is 1 commits behind head on main.

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     
Flag Coverage Δ
unittests 98.17% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sibisrinivasb

Copy link
Copy Markdown
Contributor Author

Thanks for the review! All applied as suggested:

  • cells are sorted by top-left before the span loop, so the output no longer depends on the order of table.cells
  • rows fully covered from above are dropped and the row spans crossing them shrink
  • the three tests are added to test_table_html_export_spans
  • the whitespace check and the docs sentence are in

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 row_end, col_end to the key (the one ending first wins) with a test. Happy to change the rule or drop the commit if you prefer.

@felixdittrich92
felixdittrich92 merged commit 46fa0ad into mindee:main Nov 13, 2026
57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ext: tests Related to tests folder module: io Related to doctr.io topic: table comprehension Related to table comprehension type: enhancement Improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants