Skip to content

find_tables(): opt-in layout union, grid refinement, merged-cell/header model, and Table.to_html()#5057

Merged
veget-able merged 2 commits into
pymupdf:mainfrom
veget-able:table-refine-union-html
Jul 22, 2026
Merged

find_tables(): opt-in layout union, grid refinement, merged-cell/header model, and Table.to_html()#5057
veget-able merged 2 commits into
pymupdf:mainfrom
veget-able:table-refine-union-html

Conversation

@veget-able

Copy link
Copy Markdown
Contributor

This adds an opt-in table model to page.find_tables(), plus one always-on correctness fix. Harald and Robin have already reviewed the substance of this work on a fork branch; this PR is the same code rebased onto current main with review-oriented comments reduced to normal docstrings. The long-form write-up (decision rationale, measurement appendix) lives here: https://github.com/veget-able/PyMuPDF/blob/single-diff/table-refine-html/UPSTREAM-PROPOSAL.md

What's in it (commit 1):

  • find_tables(use_layout=) — explicit opt-out of the 1.28.0 layout gating (default True keeps today's behavior).
  • find_tables(refine=) — grid refinement: splits rows the ruling-line grid merged (using cell background shading), under-segmented columns, and over-merged body rows; resolves merged-cell structure into Table.placements (a row-major grid of SpanCell with colspan/rowspan and td/th tags), plus Table.header_rows / Table.section_rows.
  • find_tables(use_layout=True, union=) — fuses the layout analyzer's table grids with the line finder's candidates: a candidate can replace a weak grid, split an over-wide one, or be appended where layout missed a table. This also supersedes the make_table_from_bbox path on the layout route, which currently returns empty silently (block-type mismatch; detailed in the write-up).
  • Table.to_html() — serializes the tagged model, as a companion to to_markdown() / to_pandas().
  • Always-on correctness fix: the detector's global CHARS/EDGES state becomes call-local (ContextVar) and each Table snapshots its characters. In released 1.28.0, re-extracting a table after a later find_tables() call returns wrong text; a deterministic regression test is included.

Commit 2 (independent speed fix): cells_to_tables()'s no-text probe passed the module-global TEXTPAGE to page.get_textbox(), but that global is never assigned — every probe built and Python-walked a fresh full-page TextPage per candidate (~24 ms each). The probe now scans the call's existing CHARS with the same strict-overlap rule as JM_rects_overlap(). Results are hash-identical across a 503-page corpus; find_tables() mean wall time drops 106 → 76 ms/page (−28%).

Measured (ParseBench table group, 503 pages, GTRM): official 1.28.0 stack scores 56.73; with this PR and the companion pymupdf4llm PR opted in, 72.11. Per-stage leave-one-out: union −7.6, refine −6.9, spans −5.0, header tagging −6.5.

Non-opt-in users are unaffected: all new keywords default off (use_layout=True matches 1.28.0), default-path output is byte-identical (verified by output hashing), default timing +~1% before commit 2 and −28% after it.

Structure question for maintainers: the model lives in four private sibling modules (_table_refine.py, _table_spans.py, _table_union.py, _table_headers.py) next to table.py, re-exported through pymupdf.table. If a subpackage, different names, or folding into table.py fits the project better, we're happy to reshape — none of that structure is precious to us.

Coupling: the companion PR on pymupdf/RAG (table_output="html") hard-depends on this one; this PR stands alone.

Tests: 30 added in tests/test_tables.py (stale-CHARS regression, use_layout gating, refine/placements/tags, union fusion and degradation, to_html). docs/ and changes.txt entries to follow once the direction and module layout are confirmed.


@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@veget-able

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@veget-able

Copy link
Copy Markdown
Contributor Author

recheck

@veget-able
veget-able force-pushed the table-refine-union-html branch from 4fb7a18 to 236c67b Compare July 22, 2026 11:19
github-actions Bot added a commit that referenced this pull request Jul 22, 2026
@veget-able
veget-able force-pushed the table-refine-union-html branch from 236c67b to c4babcb Compare July 22, 2026 11:38
…, to_html

Adds an opt-in table model to page.find_tables():

- use_layout= makes the 1.28.0 layout gating explicit (default True,
  behavior unchanged).
- refine= splits rows merged by the ruling-line grid (using cell
  background shading), under-segmented columns and over-merged body
  rows, then resolves merged-cell structure into Table.placements -- a
  row-major grid of SpanCell placements carrying colspan/rowspan and
  td/th tags -- with header metadata in Table.header_rows and
  Table.section_rows.
- union= (with use_layout=True) fuses the layout analyzer's table
  grids with the line finder's candidates: grid replacement, split of
  over-wide grids, append of tables the layout missed.
- Table.to_html() serializes the tagged model, as a companion to
  to_markdown() and to_pandas().

Also one always-on correctness fix: the detector's global CHARS/EDGES
working state becomes call-local (ContextVar) and every Table snapshots
its characters -- in released 1.28.0, re-extracting a table after a
later find_tables() call returns the later call's text. A deterministic
regression test is included.

The model lives in four private sibling modules (_table_refine,
_table_spans, _table_union, _table_headers), re-exported through
pymupdf.table. All new keywords default off; default-path output is
byte-identical (verified by output hashing) at ~+1% find_tables timing.
On ParseBench's table group (503 pages, GTRM) the official stack scores
56.73 and the opted-in stack 72.11 together with the companion
pymupdf4llm change. Adds 30 tests in tests/test_tables.py.
cells_to_tables()'s no-text filter passed the module-global TEXTPAGE to
page.get_textbox(), but that global is never assigned anywhere -- its
comment ('textpage for cell text extraction') suggests a global
statement was lost at some point; make_chars() and find_tables() only
ever bind locals of the same name. Every probe therefore built a fresh
full-page TextPage and Python-walked all of its characters
(JM_copy_rectangle), ~24ms per candidate table -- the largest single
find_tables() cost after character extraction (~30% of wall time on a
503-page table corpus).

The same characters are already available in CHARS in identical page
space, so scan those instead (built lazily, only when a candidate
survives the geometric checks), with the same strict-overlap rule as
JM_rects_overlap() and the same whitespace-only rejection. Drop the
now-unreferenced TEXTPAGE global.

Measured on the 503-page corpus: every produced table's (bbox, cells)
is hash-identical; find_tables() mean wall time drops 106.1 -> 75.9
ms/page (-28%).
@veget-able
veget-able force-pushed the table-refine-union-html branch from c4babcb to 804b0de Compare July 22, 2026 11:52
@JorjMcKie
JorjMcKie self-requested a review July 22, 2026 12:22
@veget-able
veget-able merged commit e078674 into pymupdf:main Jul 22, 2026
3 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 22, 2026
@veget-able
veget-able deleted the table-refine-union-html branch July 22, 2026 17:12
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants