This note is for pymupdf/mupdf upstream developers evaluating whether MuPDF's
native table detector (fz_table_hunt / fz_find_table_within_bounds, "TableHunter"
below) can replace pymupdf's Python TableFinder (page.find_tables(), slated
for removal) as the table-detection backend for a downstream table-to-HTML
pipeline.
Short version: TableHunter, driven by GNN-layout table boxes, finds the
same table regions TableFinder finds — that part works well. But on those
same regions it produces a much finer grid than TableFinder's ruling-line
grid (4x-16x more cells across every example below), and substituting it in
measurably hurts table-extraction quality on a 503-page benchmark. There is
also a latent type-mismatch bug in the existing make_table_from_bbox Python
wrapper that makes it silently unusable today.
Everything with a number attached below was produced by running the two
scripts in this directory in a fresh shell against plain pymupdf — see
Provenance for exactly what was re-run this session vs. cited
from a prior 503-page benchmark.
tablehunter-comparison/
├── README.md -- this file
├── compare_finder_hunter.py -- finder vs hunter grid comparison (standalone)
├── repro_make_table_from_bbox_bug.py -- minimal make_table_from_bbox bug repro
├── pdfs/ -- the five gallery source pages (single-page PDFs)
└── examples/ -- dumped finder/hunter HTML grid pairs (14 files)
TableFinder (page.find_tables, lines_strict) |
TableHunter (fz_find_table_within_bounds, GNN-box-bounded) |
|
|---|---|---|
| Region recovery (finder-only bordered candidates, given a GNN table box) | reference | 22/22 recovered, IoU 0.867-0.999 vs. finder's bbox |
| Grid quality on those same recovered regions | reference (ruling-line-exact grid) | 4.1x-15.7x more cells than finder, on every one of the 5 examples tested below (over-segmentation) |
| GTRM (structural-similarity score, 0-1, higher is better; 503-page ParseBench table benchmark; TableHunter used as a union candidate source) | 0.7211 | 0.5206 (-0.2005) |
pymupdf.table.make_table_from_bbox wrapper |
n/a | broken: always returns [] (type-mismatch bug, see below) |
| Verdict | keep as the default/primary detector | correctly proposes where tables are; does not yet produce finder-grade grids there |
Robin reproduced gallery case 1 with the stext option set pymupdf-layout uses
when extracting the page and got the correct 4x4 grid, suspecting an option
mismatch on our side. He was right. TABLE_DETECTOR_FLAGS used a reduced set
(accurate-bboxes, segment, vectors, mediabox-clip); it now matches the layout
set (preserve-images, preserve-whitespace, preserve-ligatures, accurate-bboxes,
vectors, collect-styles, segment, paragraph-break, structured, clip,
fuzzy-vectors, lazy-vectors), with table-hunt excluded because the bounded
detector is invoked explicitly per box.
With the matched flags (fresh finder/hunter HTML pairs in
examples-layout-flags/):
| case | reduced flags (old) | layout-matched flags (new) |
|---|---|---|
| 1657221585_page62 | wrong grid | 4x4 = 16 TDs, matches the finder exactly (resolved) |
| 1634690602_page7 | no table | found, 28 rows / 165 TDs vs finder 5x3 = 15 cells (11.0x) |
| 1634690602_page2 | no table | found, 27 rows / 161 TDs vs finder 4x3 = 12 cells (13.4x) |
| 1653739079_page39 | no table | all 3 tables found, 12 rows / 96-108 TDs each vs finder 12-14 cells (7.7-8.0x) |
| 203924_..._page27 | no table | found, 14 rows / 91 TDs vs finder 6x5 = 22 cells (4.1x) |
GTRM on the 503-page table benchmark: 0.5206 -> 0.5185 (unchanged in substance; finder path 0.7211). With the right options the failures collapse into the single over-segmentation mode. Region recovery stops being an issue for the bounded calls, which also recover the page39/page27 regions that a page-wide hunt misses.
Ground-truth grids for the five gallery pages are bundled under gt/
(extracted from the ParseBench table dataset) so they can be opened next to
the finder/hunter dumps.
Prerequisites: pymupdf >= 1.28 with a build that ships the table detector
(pymupdf.mupdf.fz_find_table_within_bounds, pymupdf.table.TABLE_DETECTOR_FLAGS)
and some PDFs with tables. pymupdf.layout (the GNN layout wheel) is optional —
both scripts detect its absence and fall back automatically (see "bounds modes"
below). We ran everything with PYTHONUTF8=1 set (Windows console; avoids
UnicodeEncodeError on non-ASCII table text) — harmless, and unnecessary on
most Unix shells.
# 1. side-by-side grid comparison for one PDF (first page by default)
PYTHONUTF8=1 python compare_finder_hunter.py path/to/your.pdf
# a specific page, and HTML dumps of every finder/hunter grid found
PYTHONUTF8=1 python compare_finder_hunter.py path/to/your.pdf --page 3 --dump-html out/
# 2. the make_table_from_bbox wrapper bug, against your own PDF
PYTHONUTF8=1 python repro_make_table_from_bbox_bug.py path/to/your.pdf
# (or with no argument at all -- it defaults to pdfs/1657221585_page6.pdf)No PDFs of your own needed: the gallery's five source pages (plus the bug
repro's default page) ship in pdfs/ — single-page extracts from ParseBench's
public table document set — so every number and HTML pair in this document
regenerates from a bare checkout:
for f in pdfs/*.pdf; do PYTHONUTF8=1 python compare_finder_hunter.py "$f" --dump-html examples/; done
PYTHONUTF8=1 python repro_make_table_from_bbox_bug.pyWith pymupdf.layout installed the hunter bounds come from the GNN table
boxes (the mode the committed gallery used); without it the scripts fall back
to finder-bbox bounds automatically (see the bounds-mode note below).
Exactly as run for this document (from inside tablehunter-comparison/, against
the envs/improved pymupdf 1.28.0 + pymupdf-layout 1.28.0 environment used
throughout our investigation):
PYTHONUTF8=1 ../envs/improved/Scripts/python.exe compare_finder_hunter.py \
"../ParseBench/data/docs/table/1657221585_page62.pdf" --dump-html examplesBounds modes (both exercised this session — see Provenance):
pymupdf.layoutimportable → the script callspage.get_layout()(the official pymupdf surface, no fork/helper involved) and uses the GNN"table"-class box with the best IoU against each TableFinder bbox as the bounds passed tofz_find_table_within_bounds. Printed asbounds mode: gnn.pymupdf.layoutnot installed → falls back to the TableFinder bbox itself (padded 3pt) as the bounds. Printed asbounds mode: finder-bbox. We ran both canonical gallery examples this way underenvs/nolayout(a real pymupdf install with no layout wheel present, not a simulated flag) and found the fallback is not always equivalent to GNN-bounded mode: on1657221585_page62.pdf(GNN IoU 1.00 against the finder bbox) both modes produced an identical grid (251 TDs, 15.7x). On1634690602_page7.pdf(GNN IoU 0.94 — the GNN box is measurably larger than the finder bbox) the fallback bounds gave a different, smaller grid: 75 TDs (5.0x) vs. 105 TDs (7.0x) in GNN mode, because the two bounds genuinely differ in extent (the GNN-mode hunter bbox starts at x=20.9 vs. x=57.2 for the finder-bbox-mode bbox — the GNN box includes extra page content the finder bbox does not). Practical implication: which bounds you feed the detector measurably changes its output, on top of the over-segmentation problem itself. The gallery below uses GNN-bounded mode throughout (envs/improved,pymupdf.layoutpresent) — call that out if you re-run it without the layout wheel and see different numbers on some PDFs.
Five examples, chosen for large hunter/finder cell-count ratios and visually
unambiguous splits, spanning three unrelated source documents. All numbers
below are copy-pasted from actual compare_finder_hunter.py runs (see
Provenance) — none are hand-computed or carried over unverified from earlier
notes. "Grid RxC" is TableFinder's nominal grid shape; the cell count can be
below R x C when TableFinder itself merges spanned cells (fewer non-empty
cells than the nominal grid size) — that's TableFinder's own span handling,
not a hunter artifact. TableHunter's stext output has no such nominal
rectangular shape (see Mechanism), so it's reported as "R rows (TR elements),
N TD cells total."
| bbox | grid | cells | |
|---|---|---|---|
| finder | (68.0, 90.9, 527.2, 792.1) | 4x4 | 16 |
| hunter | (65.0, 91.0, 530.2, 792.0) | 47 rows (TR) | 251 |
region IoU (hunter bbox vs finder bbox) = 0.99. This is the largest ratio we found on the corpus: a regulatory-table page with long wrapped-paragraph cells.
finder excerpt (examples/1657221585_page62_table0_finder.html):
<tr>
<td>12.1.12</td>
<td>Yeasts products</td>
<td>All yeasts32 and parts (1) thereof obtained by cracking and/or fractionation of
yeast cells from Saccharomyces bayanus, ... vitamins and minerals</td>
<td>Moisture if < 75 % or > 97 %</td>
</tr>hunter excerpt (examples/1657221585_page62_table0_hunter.html) — the SAME
row, sliced along every line-wrap/alignment gap the uncertainty-based
segmentation treats as a candidate cut, splitting the running sentence apart
(note and/or and fractionation land in separate TDs, and the last TD
before the wrap even loses "the moisture" clause's punctuation grouping):
<tr>
<td>12.1.12</td>
<td>Yeasts products</td>
<td>All yeasts32 and parts (1) thereof obtained by cracking</td>
<td>Moisture if < 75 % or ></td>
</tr>
<tr>
<td></td><td></td>
<td>and/or</td><td>fractionation</td><td></td><td>of</td><td>yeast</td>
<td>cells from</td><td>97 %</td>
</tr>| bbox | grid | cells | |
|---|---|---|---|
| finder | (56.9, 71.1, 779.7, 519.1) | 5x3 | 15 |
| hunter | (20.9, 71.4, 779.7, 525.9) | 27 rows (TR) | 105 |
region IoU = 0.94. A veterinary-medicine CVMP recommendations table (wide page, 3 wide text columns).
finder excerpt:
<tr>
<td>Product (active substance(s))</td>
<td>CVMP meeting date</td>
<td>Recommendation - SPC change (additions to text in bold, deletions in strikethrough)</td>
</tr>hunter excerpt — the header text itself gets duplicated/re-cut across two TR's (the "(active substance(s))" fragment recurs as its own TD in the next row):
<tr>
<td></td>
<td>Product (active substance(s))</td>
<td>CVMP meeting date</td>
<td>Recommendation - SPC change (additions to text in bold, deletions in strikethrough)</td>
</tr>
<tr>
<td></td>
<td>(active substance(s))</td>
<td></td>
<td>(additions to text in bold, deletions in strikethrough)</td>
</tr>| bbox | grid | cells | |
|---|---|---|---|
| finder | (56.9, 71.1, 779.7, 521.1) | 4x3 | 12 |
| hunter | (19.4, 71.4, 779.7, 525.9) | 26 rows (TR) | 103 |
region IoU = 0.94. Same source document family as example 2, different page — same header-duplication artifact, confirming it's systematic, not a one-off:
<!-- finder -->
<tr>
<td>Product (active substance(s))</td>
<td>CVMP meeting date</td>
<td>Recommendation - SPC change (additions to text in bold, deletions in strikethrough)</td>
</tr><!-- hunter -->
<tr>
<td></td>
<td>Product (active substance(s))</td>
<td>CVMP meeting date</td>
<td>Recommendation - SPC change (additions to text in bold, deletions in strikethrough)</td>
</tr>
<tr>
<td></td><td>(active substance(s))</td><td></td>
<td>(additions to text in bold, deletions in strikethrough)</td>
</tr>| bbox | grid | cells | |
|---|---|---|---|
| finder | (45.0, 103.2, 567.0, 233.8) | 3x6 | 14 |
| hunter | (42.3, 104.6, 572.2, 233.0) | 12 rows (TR) | 108 |
region IoU = 0.97. This page has three side-by-side small tables; all three
show the same 7.7x-8.0x ratio (table 1: 14 -> 108, table 2: 12 -> 96 — see
examples/1653739079_page39_table{0,1,2}_*.html). This example shows
mid-word splitting, not just mid-sentence: "Department" becomes two TDs,
Depar / rtment.
<!-- finder -->
<tr>
<td>Year</td><td>Assessments</td><td>Department</td><td>Public</td>
</tr><!-- hunter -->
<tr>
<td></td><td>Year</td><td>Assessments</td><td>Depar</td><td>rtment</td>
<td></td><td>Pub</td><td>blic</td><td></td>
</tr>| bbox | grid | cells | |
|---|---|---|---|
| finder | (62.6, 71.1, 532.8, 637.6) | 6x5 | 22 |
| hunter | (62.6, 71.2, 532.8, 637.6) | 14 rows (TR) | 91 |
region IoU = 1.00 (exact bbox match) — the smallest ratio in the gallery, and useful evidence that even a perfect region match still over-segments: a narrower, denser policy-document table.
<!-- finder -->
<tr>
<td></td><td></td>
<td>for veterinary medicinal products (to cover technologies such as mobile devices
and apps) (D; F) 3. Investigate and develop methods to optimise the dissemination
of updated safety information to practising veterinarians and animal owners
(food-producing animals and companion animals) (B; G)</td>
<td></td><td></td>
</tr><!-- hunter -->
<tr>
<td></td><td></td><td>3.</td>
<td>for veterinary medicinal products (to cover technologies such as mobile devices
and apps) (D; F) Investigate and develop methods to optimise the dissemination
of updated safety information to practising veterinarians and animal owners
(food- producing animals and companion animals) (B; G)</td>
<td></td><td></td><td></td>
</tr>(the leading list marker "3." is sheared off into its own TD)
TableHunter is not "worse" at finding tables — section above shows it recovers the same regions TableFinder does. The gap is in how it partitions a recovered region into cells, and it comes down to three things:
1. It does consume ruling lines. pymupdf.table.TABLE_DETECTOR_FLAGS
(PyMuPDF-private/src/table.py:148-154) is
TEXT_ACCURATE_BBOXES | TEXT_SEGMENT | TEXT_COLLECT_VECTORS | TEXT_MEDIABOX_CLIP
— TEXT_COLLECT_VECTORS is what hands MuPDF's stext-table detector the
page's drawn vector lines/rects as candidate ruling lines. Per our
source-level investigation notes, this consumption happens in MuPDF's
stext-table.c (h_line/v_line accumulation, roughly lines 1107-1563 in the
version we looked at) — you have that file locally; we don't vendor mupdf's C
source in this checkout, so treat that line range as a pointer to check
against your own tree, not something re-verified in this session.
2. But segmentation is uncertainty-scored, not "is there a ruling line here."
The block MuPDF's grid detector actually builds carries, per axis, a list of
(pos, uncertainty) candidate cut positions plus a max_uncertainty (see
JM_make_grid_block, PyMuPDF-private/src/extra.i:3689-3711, which is what
would expose a FZ_STEXT_BLOCK_GRID block's xpos/ypos/max_uncertain to
Python — see the bug section below for why that path is currently dead).
pymupdf's own (also-dead) make_table_from_bbox logic
(PyMuPDF-private/src/table.py:227-240) confirms this reading: it explicitly
throws away any y position with yunc > 0 before treating it as a row
boundary. In other words, MuPDF's own model of this data is "some of these
candidate cuts are real grid lines and some aren't; you must filter." The
fz_find_table_within_bounds -> stext-XML path that is actually usable today
(what both scripts in this repo use) does not apply any such filter before
emitting TDs — every candidate cut becomes a real cell boundary in the
output.
3. stext has no rowspan/colspan representation. We dumped the raw stext
XML this session and checked directly: a TD struct element carries exactly
idx, bbox, raw, std — no span attribute of any kind:
TD attribs: {'idx': '0', 'bbox': '68 90.7 110.2845 108.728359', 'raw': 'TD', 'std': 'TD'}
Per our source-level investigation notes, mupdf's stext-table.c documents
this limitation directly in a code comment around line 472. Practically, this
cuts both ways: a genuinely merged source cell becomes one big TD (that part
is fine — see the finder's single wrapped-paragraph cells above), but nothing
in the output tells a consumer "these dozen narrow TDs were really one
logical cell that merely wrapped across several text lines." Combined with
(2), every alignment gap inside a real prose cell is free to become its own
TD, which is exactly what the gallery above shows — right down to "Depar" /
"rtment" being split mid-word in example 4.
Net effect measured on the 503-page ParseBench table benchmark (details in Provenance): substituting TableHunter (GNN-box-bounded) for TableFinder as the union-candidate source in our table-to-HTML engine takes GTRM from 0.7211 to 0.5206 — a 0.2005 drop — even though region coverage barely changes. The over-segmented grids actively hurt downstream structural quality; they are not merely "more detail."
pymupdf.table.make_table_from_bbox (and get_table_dict_from_rect /
pymupdf.extra.make_table_dict underneath it) is pymupdf's existing Python
entry point for consuming fz_find_table_within_bounds. It silently returns
no cells for every table, always, on this build.
Root cause — PyMuPDF-private/src/extra.i:3717-3738:
void make_table_dict(fz_stext_page *tp, PyObject *table_dict, PyObject *bbox)
{
fz_rect bounds = JM_rect_from_py(bbox);
fz_stext_block *block;
try {
block = mupdf::ll_fz_find_table_within_bounds(tp, bounds);
} catch (std::exception&) {
return; /* Ignore failure to find a table structure. */
}
// Check if a table structure was found
if (block && block->type == FZ_STEXT_BLOCK_GRID) { // <-- type 4
JM_make_grid_block(block, table_dict);
}
// else: table_dict silently stays {} -- no exception, no signal.
}and PyMuPDF-private/src/table.py:207-215:
def make_table_from_bbox(textpage, word_rects, rect):
cells = []
block = get_table_dict_from_rect(textpage, rect) # calls make_table_dict above
if block.get("type") != mupdf.FZ_STEXT_BLOCK_GRID:
return cells # always [] in practice
...On this pymupdf/mupdf build, fz_find_table_within_bounds never returns a
FZ_STEXT_BLOCK_GRID (type 4) block — it returns a Table STRUCT block
(FZ_STEXT_BLOCK_STRUCT, type 2). The == FZ_STEXT_BLOCK_GRID check is
therefore always false, and both wrappers fail silently. This is not a
one-off: a scan across 40 ParseBench PDFs found 0/36 finder-bbox trials ever
produced a grid through this path.
Repro (repro_make_table_from_bbox_bug.py, run this session,
envs/improved and envs/nolayout both shown):
PYTHONUTF8=1 python repro_make_table_from_bbox_bug.pyobserved output:
file: 1657221585_page6.pdf TableFinder bbox=(68.03..., 190.06..., 527.24..., 629.05...) (60 cells -- a table IS genuinely there)
T.make_table_from_bbox(...) -> 0 cells (expected > 0; got silent [])
T.get_table_dict_from_rect(...) -> {} (empty: make_table_dict only fills it when block.type == FZ_STEXT_BLOCK_GRID)
raw fz_find_table_within_bounds(...) block.type = 2 (FZ_STEXT_BLOCK_GRID=4, FZ_STEXT_BLOCK_STRUCT=2)
-> block.type != FZ_STEXT_BLOCK_GRID, so make_table_dict's `if` never fires: bug confirmed.
(also reproduced against 1657221585_page62.pdf under envs/nolayout, a real
pymupdf install with no layout wheel, confirming it's independent of the
layout wheel being present)
The workaround both scripts in this repo use instead: call
fz_find_table_within_bounds directly, then walk textpage.extractXML() for
the Table / TR / TD struct elements it wrote into the stext tree (see
hunt_within_bounds() in compare_finder_hunter.py).
None of this is a dead end — the region-recovery result means the hard part (knowing where to look) already works. Grid quality is a narrower, more mechanical gap. In rough order of expected impact:
- Ruling-line-authoritative segmentation. When
TEXT_COLLECT_VECTORSfinds a (near-)complete ruling-line grid inside the bounds, snap segmentation to exactly those lines and treat them as ground truth row/column boundaries, falling back to today's uncertainty/alignment-based cuts only where no line exists. All 5 gallery examples above are ruled tables where this alone should collapse most of the extra TDs. - A cell-merge pass keyed on the uncertainty score MuPDF already computes.
JM_make_grid_blockshows the detector already produces a per-positionuncertaintyvalue for every candidate cut — it just isn't used to gate which cuts survive into theTDstruct path. Merging TDs across a low-confidence internal cut (no ruling line, no consistent alignment evidence across rows) would directly target the "Depar"/"rtment"-style splits. - Rowspan/colspan on
TD(or an adjacency side-channel next to the struct tree), so a real spanned source cell round-trips as one logical cell instead of forcing a choice between "one oversized TD" and (via 1+2 done wrong) "many split TDs." - Fix the type-mismatch bug as a low-effort first step: reconcile
make_table_dict'sFZ_STEXT_BLOCK_GRIDcheck with whatfz_find_table_within_boundsactually returns today (FZ_STEXT_BLOCK_STRUCT) — either by having the detector still populate aFZ_STEXT_BLOCK_GRID-shaped result the existing wrapper expects, or by updatingmake_table_dict/make_table_from_bboxto read the STRUCT block and expose its (already-computed) per-cut uncertainty at the Python level. That would give downstream consumers the raw material for (2) without any new MuPDF-side computation.
The 0.7211 / 0.5206 numbers come from LlamaIndex's ParseBench, table group
(503 pages, GTRM = a composite of GriTS and TableRecordMatch). Everything
needed to reproduce them — and to score a modified hunter build in the same
pipeline — is public. The key piece is the branch
experiment/hunter-union-source at https://github.com/veget-able/PyMuPDF:
it is the review branch (single-diff/table-refine-html) plus one commit
adding an environment switch, PYMUPDF_TABLE_UNION_SOURCE=hunter, that
routes the fusion stage's candidate source through
fz_find_table_within_bounds (per layout table box) instead of the Python
line finder. Everything downstream — the GNN primaries, the
replace/split/append gates, grid refinement, span resolution, header tagging,
HTML output — is identical between the two settings, so the score difference
isolates the candidate grids themselves.
-
PyMuPDF from the branch. No compiled parts differ from 1.28.0, so the quick path is the official wheel plus a pure-Python overlay:
pip install pymupdf==1.28.0 pymupdf-layout==1.28.0 git clone -b experiment/hunter-union-source https://github.com/veget-able/PyMuPDF PKG=$(python -c "import pymupdf, os; print(os.path.dirname(pymupdf.__file__))") for f in table _table_refine _table_spans _table_union _table_headers; do cp PyMuPDF/src/$f.py "$PKG/" done
For a modified MuPDF, build PyMuPDF from that branch against your MuPDF checkout as usual — the Python files ride along, and your detector changes flow in through
fz_find_table_within_bounds. -
pymupdf4llm with the HTML table output (pure Python):
pip install git+https://github.com/veget-able/pymupdf4llm@single-diff/html-tables-v2
-
ParseBench with the
table_outputwiring:git clone https://github.com/veget-able/ParseBench cd ParseBench && pip install -e . parse-bench run pymupdf4llm_v4 --group table --max_concurrent 1 --open_report False
The dataset downloads on first run.
--max_concurrent 1matters (the layout engine carries global state). The score isaggregate_metrics.avg_grits_trm_compositein<output_dir>/pymupdf4llm_v4/_evaluation_report.json. On Windows setPYTHONUTF8=1; unnecessary elsewhere. -
Flip the candidate source and re-run:
PYMUPDF_TABLE_UNION_SOURCE=hunter parse-bench run pymupdf4llm_v4 \ --group table --max_concurrent 1 --open_report False --output_dir ./output-hunterExpected on the unmodified 1.28.0 detector: ~72.1 with the line finder, ~52.1 with the hunter (the branch as pushed re-measures at 0.7211 / 0.5205 on our machine, matching the original investigation's 0.5206). A hunter fix that closes the over-segmentation shows up directly in this number — and we are happy to run candidate builds through this harness on our side too.
What is asserted in this document and how it was checked, so nothing here is a copied-but-unverified number:
- Region recovery, 22/22, IoU 0.867-0.999: recomputed this session
directly from a prior investigation's raw per-candidate JSON (22 finder-only
bordered candidates, all recovered). Two of the 22 (
1657221585_page62.pdf,1634690602_page7.pdf) were independently re-run live this session withcompare_finder_hunter.pyand match exactly (IoU 1.00 and 0.94 respectively, cell counts as in the gallery). - All 5 gallery examples (cell counts, bboxes, IoU, HTML excerpts): run
live this session with
compare_finder_hunter.pyagainstenvs/improved/Scripts/python.exe(pymupdf 1.28.0 + pymupdf-layout 1.28.0),PYTHONUTF8=1. Theexamples/*.htmlfiles are the exact--dump-htmloutput of those runs, not hand-edited. - Gallery PDFs (
pdfs/): the source pages themselves, included verbatim (single-page extracts from ParseBench's publictabledocument set) so the gallery reproduces from a bare checkout. Re-verified after bundling:compare_finder_hunter.py pdfs/1657221585_page62.pdfreproduces the committed pair's numbers exactly (4x4 = 16 vs 47 rows / 251 TDs, region IoU 0.99, GNN-bounds IoU 1.00), and the no-argument bug repro runs againstpdfs/1657221585_page6.pdf. - Bounds-mode fallback (no
pymupdf.layout): run live this session againstenvs/nolayout/Scripts/python.exe, a real pymupdf install with no layout wheel installed (not a simulated flag), on both canonical gallery PDFs — this is what surfaced the GNN-vs-fallback divergence on1634690602_page7.pdfdocumented under "Bounds modes" above. - The
make_table_from_bboxbug repro and its observed output: run live this session, both environments, both a bundled default PDF and an explicit path argument. - GTRM 0.7211 (finder) vs. 0.5206 (TableHunter, GNN-box-bounded, used as a
union candidate source): cited from our prior 503-page ParseBench table
benchmark (
GTRM= average of GriTS and TableRecordMatch structural-match scores over ParseBench's 503-page / 284-document table set). Not rerun in this session — a full 503-page benchmark pass is out of scope for this comparison doc; treat it as a reference data point, reproducible with the ParseBench harness in this repo if you want to re-derive it yourself. - Mechanism section: the
TABLE_DETECTOR_FLAGScomposition, theJM_make_grid_blockuncertainty fields, the dead filtering logic inmake_table_from_bbox, and the "no rowspan/colspan attribute on TD" claim were all checked directly againstPyMuPDF-private/src/extra.iandPyMuPDF-private/src/table.pyin this checkout, plus a live stext-XML dump (shown verbatim above) confirmingTDattributes. Thestext-table.cline references (ruling-line consumption via FZ_STEXT_BLOCK_VECTOR walkswalk_grid_lines/walk_grid_lines2~1722-1812 feedingadd_h_line2/add_v_line2~1515-1563 into grid cells ~1107-1128; border-favoring scoring ~3726-3759; hunt entries ~4337/4468/4514; the "no explicit rowspan/colspan in stext" comment ~472-474) were read and verified against a local MuPDF source checkout during the investigation that produced this document. Line numbers drift across MuPDF versions — treat them as anchors, and re-locate the symbols in your tree if they have moved.