Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/source/user_guide/exporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ The structure of these exports depends on what the predictor detected:
- **Without layout analysis**, blocks become paragraphs.
- **With layout analysis** (`detect_layout=True`), the layout regions are used to render titles and section
headers as headings, list items as lists, and to place the page furniture (headers, footers, footnotes).
- **With table recognition** (`detect_tables=True`), tables are rendered as real tables.
- **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.

::::{tab-set}

Expand Down
51 changes: 44 additions & 7 deletions doctr/io/exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1003,18 +1003,55 @@ def render_figure(self, source: str | None, caption: str | None = None, escape:
return f'<figure><img src="{_html_escape(source, quote=True)}" alt="{alt}">{figcaption}</figure>'

def render_table(self, table: "Table", escape: bool = True) -> str:
"""Render a table as an HTML table (first row used as header)"""
"""Render a table as an HTML table (first row used as header). A cell spanning several rows / columns is
written with `rowspan` / `colspan` when the positions it covers hold no other text."""
grid = table.to_grid()
if len(grid) == 0 or len(grid[0]) == 0:
return ""
num_rows, num_cols = len(grid), len(grid[0])

# Top-left position -> (row span, column span) of the merged cells, and the positions they cover
spans: dict[tuple[int, int], tuple[int, int]] = {}
covered: set[tuple[int, int]] = set()
# The merge whose top-left comes first (row-major) wins, whatever the order of `table.cells`; with the same
# top-left, the one ending first
for cell in sorted(table.cells, key=lambda cell: (cell.row_start, cell.col_start, cell.row_end, cell.col_end)):
r0, c0 = cell.row_start, cell.col_start
r1, c1 = min(cell.row_end, num_rows - 1), min(cell.col_end, num_cols - 1)
if r0 < 0 or c0 < 0 or r1 < r0 or c1 < c0 or (r1 == r0 and c1 == c0):
continue
area = {(r, c) for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)}
others = area - {(r0, c0)}
# Keep the dense layout when the merge would hide text or cross another merged cell
if area & covered or any(pos in spans for pos in area) or any(grid[r][c].strip() for r, c in others):
continue
spans[(r0, c0)] = (r1 - r0 + 1, c1 - c0 + 1)
covered |= others

# 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()
}

def _cell(value: str, tag: str) -> str:
def _cell(value: str, tag: str, position: tuple[int, int]) -> str:
content = self.escape_text(value) if escape else value
return f"<{tag}>{content.strip()}</{tag}>"

head = "<tr>" + "".join(_cell(value, "th") for value in grid[0]) + "</tr>"
body = "\n".join("<tr>" + "".join(_cell(value, "td") for value in row) + "</tr>" for row in grid[1:])
return f"<table>\n{head}\n{body}\n</table>" if body else f"<table>\n{head}\n</table>"
row_span, col_span = spans.get(position, (1, 1))
attrs = (f' rowspan="{row_span}"' if row_span > 1 else "") + (
f' colspan="{col_span}"' if col_span > 1 else ""
)
return f"<{tag}{attrs}>{content.strip()}</{tag}>"

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
]
return "<table>\n" + "\n".join(rows) + "\n</table>"

def export_kie_page(self, page: "KIEPage", direction: str = "auto", escape: bool = True) -> str:
parts: list[str] = []
Expand Down
71 changes: 71 additions & 0 deletions tests/common/test_io_exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,77 @@ def test_table_exports():
assert page.export_as_html() == "<p>only text</p>"


def test_table_html_export_spans():
geo = ((0.1, 0.6), (0.4, 0.65))

def _table(cells, num_rows, num_cols):
return elements.Table(
cells=[elements.TableCell(value, 0.9, geo, *logic) for value, *logic in cells],
num_rows=num_rows,
num_cols=num_cols,
geometry=geo,
)

# A cell merged over rows, a cell merged over columns: written with rowspan / colspan, the covered positions skipped
merged = _table(
[
("Item", 0, 1, 0, 0),
("Revenue", 0, 0, 1, 2),
("2023", 1, 1, 1, 1),
("2024", 1, 1, 2, 2),
("Widgets", 2, 2, 0, 0),
("10", 2, 2, 1, 1),
("12", 2, 2, 2, 2),
],
3,
3,
)
assert HTMLExporter().render_table(merged) == (
'<table>\n<tr><th rowspan="2">Item</th><th colspan="2">Revenue</th></tr>\n'
"<tr><td>2023</td><td>2024</td></tr>\n"
"<tr><td>Widgets</td><td>10</td><td>12</td></tr>\n</table>"
)
# The dense grid of the other formats is unchanged
assert merged.to_grid() == [["Item", "Revenue", ""], ["", "2023", "2024"], ["Widgets", "10", "12"]]

# A merge that would hide the text of another cell is not written: the dense layout is kept, no text is lost
hiding = _table([("wide", 0, 0, 0, 1), ("inside", 0, 0, 1, 1), ("a", 1, 1, 0, 0), ("b", 1, 1, 1, 1)], 2, 2)
assert HTMLExporter().render_table(hiding) == (
"<table>\n<tr><th>wide</th><th>inside</th></tr>\n<tr><td>a</td><td>b</td></tr>\n</table>"
)

# A span reaching outside the grid is clipped to it
clipped = _table([("head", 0, 0, 0, 5), ("a", 1, 1, 0, 0), ("b", 1, 1, 1, 1)], 2, 2)
assert HTMLExporter().render_table(clipped) == (
'<table>\n<tr><th colspan="2">head</th></tr>\n<tr><td>a</td><td>b</td></tr>\n</table>'
)

# 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>'
)

# Merges sharing their top-left: the one ending first wins, whatever the order of the cells
down, across = ("", 0, 1, 0, 0), ("", 0, 0, 0, 1)
expected = '<table>\n<tr><th colspan="2"></th></tr>\n<tr><td></td><td></td></tr>\n</table>'
assert HTMLExporter().render_table(_table([down, across], 2, 2)) == expected
assert HTMLExporter().render_table(_table([across, down], 2, 2)) == expected


# --- XML / JSON ------------------------------------------------------------------------------------------------------


Expand Down
Loading