find_tables(): opt-in layout union, grid refinement, merged-cell/header model, and Table.to_html()#5057
Merged
Conversation
Contributor
|
All contributors have signed the CLA ✍️ ✅ |
Contributor
Author
|
I have read the CLA Document and I hereby sign the CLA |
Contributor
Author
|
recheck |
veget-able
force-pushed
the
table-refine-union-html
branch
from
July 22, 2026 11:19
4fb7a18 to
236c67b
Compare
veget-able
force-pushed
the
table-refine-union-html
branch
from
July 22, 2026 11:38
236c67b to
c4babcb
Compare
…, 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
force-pushed
the
table-refine-union-html
branch
from
July 22, 2026 11:52
c4babcb to
804b0de
Compare
JorjMcKie
self-requested a review
July 22, 2026 12:22
JorjMcKie
approved these changes
Jul 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdWhat's in it (commit 1):
find_tables(use_layout=)— explicit opt-out of the 1.28.0 layout gating (defaultTruekeeps 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 intoTable.placements(a row-major grid ofSpanCellwith colspan/rowspan and td/th tags), plusTable.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 themake_table_from_bboxpath 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 toto_markdown()/to_pandas().CHARS/EDGESstate becomes call-local (ContextVar) and eachTablesnapshots its characters. In released 1.28.0, re-extracting a table after a laterfind_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-globalTEXTPAGEtopage.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 existingCHARSwith the same strict-overlap rule asJM_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=Truematches 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 totable.py, re-exported throughpymupdf.table. If a subpackage, different names, or folding intotable.pyfits 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/andchanges.txtentries to follow once the direction and module layout are confirmed.