From 5f2ccce52da9aa446297e14560e8d8940fb137eb Mon Sep 17 00:00:00 2001 From: Halleluyah Oludele Date: Sun, 2 Aug 2026 23:42:57 +0100 Subject: [PATCH] feat(table): StrategyAuto for one-axis-ruled tables (opt-in) The ICDAR 2013 evaluation found 28 of 125 documents where pdftable detected no table at all, and blamed tables ruled on one axis only: "lines" builds cells from INTERSECTING rulings, so a horizontally-ruled table yields none. Measurement confirmed it exactly -- us-017 has 218 horizontal rules and 0 vertical, us-018 226 and 0, us-025 225 and 0, and all of them go from 0 tables to 3-7 with the other axis inferred. StrategyAuto picks per axis: lines where that axis is ruled, text where it is not BUT the other axis is, and lines when neither is. That last branch is the load-bearing one. Falling back to text on an unruled page is what makes a naive lines->text fallback score worse than lines alone -- precision 0.223 against 0.865 -- because prose has word alignment too and the text strategy will report a table for it. Rulings on the other axis are the evidence that a table is actually present; without that evidence Auto declines to guess. It is NOT a default, because the hypothesis it was built to test came back negative and the number is honest about it: lines precision 0.865 recall 0.229 F1 0.362 auto precision 0.797 recall 0.231 F1 0.358 Detection improved as predicted -- documents with no table found drop from 28 to 23, tables detected rise from 306 to 331. The tables it finds there are gridded badly, so quality on the scored set falls from 0.556 to 0.400 once those harder documents enter it, and recall barely moves. That corrects the earlier conclusion. "The bottleneck is detection, not structure" was inferred from precision on the documents lines could already see -- a population self-selected by being fully ruled. On the harder ones structure is weak too: knowing where a table is does not tell you where its columns are. Both are weak on one-axis-ruled layouts, and fixing detection alone converts almost nothing. Shipped anyway because it is correct for its stated case and a caller who knows their corpus is booktabs-ruled gets a real improvement. The negative result is recorded in docs/evaluations so the next person does not re-run the experiment blind. --- .../__pycache__/score.cpython-313.pyc | Bin 0 -> 11002 bytes bench/icdar2013/diag.py | 2 +- bench/icdar2013/extract.go | 12 ++ bench/icdar2013/score.py | 5 +- docs/README.md | 1 + ...026-08-02-strategy-auto-negative-result.md | 105 ++++++++++++++++ finder.go | 2 +- page.go | 58 ++++++++- strategy_auto_test.go | 118 ++++++++++++++++++ table.go | 28 +++++ 10 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 bench/icdar2013/__pycache__/score.cpython-313.pyc create mode 100644 docs/evaluations/2026-08-02-strategy-auto-negative-result.md create mode 100644 strategy_auto_test.go diff --git a/bench/icdar2013/__pycache__/score.cpython-313.pyc b/bench/icdar2013/__pycache__/score.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..678893b881b408e4e6e8b011f5628f8909821861 GIT binary patch literal 11002 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 erpB|jDiUey_ int: for pdf, xml in pairs: gt = gt_relations(xml) out = subprocess.run( - [exe, "-strategy", "lines", pdf], capture_output=True, timeout=120 + [exe, "-strategy", os.environ.get("DIAG_STRATEGY","lines"), pdf], capture_output=True, timeout=120 ).stdout tables = json.loads(out or b"[]") detected_tables_total += len(tables) diff --git a/bench/icdar2013/extract.go b/bench/icdar2013/extract.go index ba010bd..255cd04 100644 --- a/bench/icdar2013/extract.go +++ b/bench/icdar2013/extract.go @@ -39,12 +39,24 @@ func main() { lines := mk(pdftable.StrategyLines, pdftable.StrategyLines) text := mk(pdftable.StrategyText, pdftable.StrategyText) + // "mixed" is the booktabs case: horizontal rules give the rows, word + // alignment gives the columns. A table ruled only horizontally has no + // ruling intersections at all, so pure "lines" cannot see it. + mixed := mk(pdftable.StrategyText, pdftable.StrategyLines) + auto := mk(pdftable.StrategyAuto, pdftable.StrategyAuto) + var attempts []pdftable.TableSettings switch *strategy { case "lines": attempts = []pdftable.TableSettings{lines} case "text": attempts = []pdftable.TableSettings{text} + case "mixed": + attempts = []pdftable.TableSettings{mixed} + case "lines-then-mixed": + attempts = []pdftable.TableSettings{lines, mixed} + case "auto": + attempts = []pdftable.TableSettings{auto} default: // fallback: ruled cells first, whitespace alignment if none attempts = []pdftable.TableSettings{lines, text} } diff --git a/bench/icdar2013/score.py b/bench/icdar2013/score.py index 1e3fdb8..9e26dc6 100644 --- a/bench/icdar2013/score.py +++ b/bench/icdar2013/score.py @@ -154,10 +154,9 @@ def main() -> int: systems = { "pdftable (lines)": lambda p: run_pdftable(exe, p, "lines", False), - "pdftable (fallback)": lambda p: run_pdftable(exe, p, "fallback", False), - "pdftable (fallback+merge)": lambda p: run_pdftable(exe, p, "fallback", True), + "pdftable (AUTO)": lambda p: run_pdftable(exe, p, "auto", False), + "pdftable (AUTO +merge)": lambda p: run_pdftable(exe, p, "auto", True), "pdfplumber (lines)": lambda p: run_pdfplumber(p, "lines"), - "pdfplumber (text)": lambda p: run_pdfplumber(p, "text"), } totals = {k: [0, 0, 0] for k in systems} diff --git a/docs/README.md b/docs/README.md index 41c9e96..dceb3ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ release history in [`CHANGELOG.md`](../CHANGELOG.md). | date | subject | headline | | --- | --- | --- | | [2026-08-02](evaluations/2026-08-02-icdar2013-table-structure.md) | ICDAR 2013 table detection + structure | F1 0.362 end-to-end; the bottleneck is **detection**, not cell accuracy | +| [2026-08-02](evaluations/2026-08-02-strategy-auto-negative-result.md) | `StrategyAuto` for one-axis-ruled tables | **negative result** — detection 22%→18% missed, but F1 0.362→0.358. Shipped opt-in only | | [2026-08-02](evaluations/2026-08-02-font-metrics-and-table-fidelity.md) | font metrics and table fidelity | position drift 11.99pt → **0.0000pt**; negative-sign loss 19% → **0%** | ### Conventions diff --git a/docs/evaluations/2026-08-02-strategy-auto-negative-result.md b/docs/evaluations/2026-08-02-strategy-auto-negative-result.md new file mode 100644 index 0000000..ae88e63 --- /dev/null +++ b/docs/evaluations/2026-08-02-strategy-auto-negative-result.md @@ -0,0 +1,105 @@ +# StrategyAuto — improves detection, does not improve accuracy + +**Date:** 2026-08-02 +**Commit:** `05c0c92` + `StrategyAuto` +**Harness:** [`bench/icdar2013`](../../bench/icdar2013/) +**Verdict:** shipped as **opt-in**; does **not** become a default. The +hypothesis it tested is disproved. + +## Hypothesis + +The [ICDAR 2013 evaluation](2026-08-02-icdar2013-table-structure.md) found +28 of 125 documents (22%) where pdftable detected **no table at all**, and +attributed it to tables ruled on one axis only. `lines` builds cells from +*intersecting* rulings, so a horizontally-ruled table yields none. + +Confirmed directly — every zero-detection document had horizontal rules +and zero vertical ones: + +``` +us-017: 218 H, 0 V lines=0 mixed=6 +us-018: 226 H, 0 V lines=0 mixed=7 +us-024: 135 H, 0 V lines=0 mixed=4 +us-025: 225 H, 0 V lines=0 mixed=3 +``` + +So: pick `lines` for the ruled axis and `text` for the unruled one, but +**only when the other axis is ruled** — those rulings being the evidence +that a table is genuinely present. Expectation: recall rises, precision +holds. + +## Result — the hypothesis was wrong + +| system | precision | recall | F1 | +| --- | --- | --- | --- | +| pdftable (`lines`) | 0.865 | 0.229 | **0.362** | +| pdftable (`auto`) | 0.797 | 0.231 | **0.358** | +| pdftable (`auto` + MergeSplitTokens) | 0.826 | 0.230 | 0.359 | +| pdfplumber (`lines`) | 0.868 | 0.235 | 0.370 | + +Recall moved 0.229 → 0.231. Precision fell 0.865 → 0.797. **Net slightly +worse.** + +## Why — and it corrects the earlier conclusion + +Detection did improve, exactly as predicted: + +| | `lines` | `auto` | +| --- | --- | --- | +| documents with no table found | 28 (22%) | **23 (18%)** | +| tables detected | 306 | **331** | +| F1 *on documents where one was found* | 0.556 | **0.400** | + +We now find tables in five more documents. **The tables we find there are +gridded badly.** Once the newly-detected hard documents enter the scored +set, quality on that set falls from 0.556 to 0.400. + +The earlier report concluded "the bottleneck is detection, not +structure", reasoning from precision 0.865 on detected documents. That +was true of the documents `lines` could already see — an easier +population, self-selected by being fully ruled. On the harder ones, +**structure is weak too**: knowing where the table is does not tell you +where its columns are, and word-alignment clustering does not recover +them on these layouts. + +Corrected statement: **detection and structure are both weak on +one-axis-ruled tables. Fixing detection alone converts almost nothing.** + +## What was shipped, and why anything at all + +`StrategyAuto` is available per axis and is **not** a default. It is +correct for its stated case — it finds tables that `lines` cannot see — +and a caller who knows their corpus is booktabs-ruled gets a real +improvement. `TestAutoIsNotTheDefault` pins that it stays opt-in. + +The conservative rule is load-bearing and worth keeping even though the +headline did not move. Auto declines to guess when *neither* axis is +ruled. Without that guard, a naive `lines`→`text` fallback scores 0.223 +precision — prose has word alignment too, and the text strategy reports a +table for it. That is why the fallback row in the earlier report is worse +than `lines` alone. + +## What this implies for the next attempt + +Do not spend more effort on heuristics for finding the table region. The +measurement says the missing piece is **row and column structure** on +layouts where the rules do not supply it. + +That is a different shape of problem, and it maps onto what layout models +actually output. Table Transformer and similar predict rows, columns and +spanning cells — not merely a table bounding box. That is the part +geometry cannot recover here. + +Revised split for the hybrid: + +- **layout/VLM model → rows, columns and spans** (not just "where is the + table") +- **pdftable text layer → cell contents and coordinates**, which stays + exact and keeps citation geometry + +## Reproduce + +```sh +python bench/icdar2013/run.py +DIAG_STRATEGY=auto python bench/icdar2013/diag.py +``` diff --git a/finder.go b/finder.go index 2ae0bd7..73c1b46 100644 --- a/finder.go +++ b/finder.go @@ -869,7 +869,7 @@ func ensureSupportedStrategies(s TableSettings) error { {"horizontal", s.HorizontalStrategy}, } { switch pair.strategy { - case StrategyLines, StrategyLinesStrict, StrategyText, StrategyExplicit: + case StrategyLines, StrategyLinesStrict, StrategyText, StrategyExplicit, StrategyAuto: // ok default: return fmt.Errorf("%w: unknown %s_strategy %q", ErrUnsupported, pair.axis, pair.strategy) diff --git a/page.go b/page.go index 4131535..7974fb9 100644 --- a/page.go +++ b/page.go @@ -460,9 +460,27 @@ func (p *page) findTableEdges(s TableSettings) ([]layout.Edge, error) { pageWidth := p.Width() pageHeight := p.Height() + // Resolve StrategyAuto now that the page's drawn edges are known. + vStrategy := resolveAuto(s.VerticalStrategy, layout.Vertical, lineLikeEdges) + hStrategy := resolveAuto(s.HorizontalStrategy, layout.Horizontal, lineLikeEdges) + + // Auto may have turned an axis into "text" after the fact, so the + // words it needs might not have been fetched above. + if words == nil && (vStrategy == StrategyText || hStrategy == StrategyText) { + opts := DefaultWordOpts() + opts.XTolerance = s.TextTolerance + opts.YTolerance = s.TextTolerance + opts.KeepBlankChars = s.KeepBlankChars + w, err := p.Words(opts) + if err != nil { + return nil, err + } + words = w + } + // Per-axis base edge derivation. - vEdges := p.baseEdges(s.VerticalStrategy, layout.Vertical, lineLikeEdges, words, s) - hEdges := p.baseEdges(s.HorizontalStrategy, layout.Horizontal, lineLikeEdges, words, s) + vEdges := p.baseEdges(vStrategy, layout.Vertical, lineLikeEdges, words, s) + hEdges := p.baseEdges(hStrategy, layout.Horizontal, lineLikeEdges, words, s) // Explicit overrides are added on top of whichever base set was // chosen. With StrategyExplicit the base set is empty so the @@ -493,8 +511,42 @@ func (p *page) findTableEdges(s TableSettings) ([]layout.Edge, error) { // drawn primitives (Lines / Rects / Curves), i.e. whether // findTableEdges needs to call Objects(). Text and explicit // strategies don't. +// +// StrategyAuto counts: it cannot decide anything without seeing what the +// page drew, and it may well resolve to "lines". func isLineLike(s TableStrategy) bool { - return s == StrategyLines || s == StrategyLinesStrict + return s == StrategyLines || s == StrategyLinesStrict || s == StrategyAuto +} + +// minEdgesForAxis is how many rulings an axis needs before Auto treats it +// as genuinely ruled. Two is the floor that can bound a cell; a single +// stray rule (an underline, a header separator, a page border) is not a +// table and must not be read as evidence of one. +const minEdgesForAxis = 2 + +// resolveAuto turns StrategyAuto into a concrete strategy for one axis, +// using the edges the page actually drew. Every other strategy passes +// through untouched. +// +// See StrategyAuto for why the "neither axis is ruled" case deliberately +// resolves to lines rather than text. +func resolveAuto(s TableStrategy, orientation layout.Orientation, lineLikeEdges []layout.Edge) TableStrategy { + if s != StrategyAuto { + return s + } + other := layout.Horizontal + if orientation == layout.Horizontal { + other = layout.Vertical + } + if len(layout.FilterEdgesByOrientation(lineLikeEdges, orientation)) >= minEdgesForAxis { + return StrategyLines + } + if len(layout.FilterEdgesByOrientation(lineLikeEdges, other)) >= minEdgesForAxis { + // The other axis is ruled, so a table is really here — we just + // have to infer this axis from word alignment. + return StrategyText + } + return StrategyLines } // baseEdges returns the per-axis edges produced by the named strategy. diff --git a/strategy_auto_test.go b/strategy_auto_test.go new file mode 100644 index 0000000..42b6c60 --- /dev/null +++ b/strategy_auto_test.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Halleluyah Oludele +// Licensed under the MIT License. + +package pdftable + +import ( + "testing" + + "github.com/hallelx2/pdftable/internal/layout" +) + +func vEdge(x, y0, y1 float64) layout.Edge { + return layout.Edge{X0: x, X1: x, Y0: y0, Y1: y1, Orientation: layout.Vertical} +} + +func hEdge(y, x0, x1 float64) layout.Edge { + return layout.Edge{X0: x0, X1: x1, Y0: y, Y1: y, Orientation: layout.Horizontal} +} + +// TestResolveAutoPicksPerAxis covers the decision table for StrategyAuto. +// +// The case it exists for is the table ruled on one axis only — booktabs +// style, the house style of most government and academic publishing. +// Such a table has no ruling intersections at all, so "lines" finds +// nothing on either axis. +func TestResolveAutoPicksPerAxis(t *testing.T) { + fullGrid := []layout.Edge{ + vEdge(100, 0, 50), vEdge(200, 0, 50), + hEdge(0, 100, 200), hEdge(50, 100, 200), + } + horizOnly := []layout.Edge{ + hEdge(0, 100, 200), hEdge(25, 100, 200), hEdge(50, 100, 200), + } + vertOnly := []layout.Edge{ + vEdge(100, 0, 50), vEdge(150, 0, 50), vEdge(200, 0, 50), + } + + cases := []struct { + name string + edges []layout.Edge + wantV TableStrategy + wantH TableStrategy + why string + }{ + { + "fully ruled", fullGrid, StrategyLines, StrategyLines, + "both axes are ruled, so neither needs inferring", + }, + { + "horizontal rules only", horizOnly, StrategyText, StrategyLines, + "rows come from the rules; columns must be inferred from words", + }, + { + "vertical rules only", vertOnly, StrategyLines, StrategyText, + "the mirror image: columns from rules, rows inferred", + }, + { + "no rulings at all", nil, StrategyLines, StrategyLines, + "NOT text/text — see below", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotV := resolveAuto(StrategyAuto, layout.Vertical, tc.edges) + gotH := resolveAuto(StrategyAuto, layout.Horizontal, tc.edges) + if gotV != tc.wantV || gotH != tc.wantH { + t.Errorf("v=%q h=%q, want v=%q h=%q (%s)", gotV, gotH, tc.wantV, tc.wantH, tc.why) + } + }) + } +} + +// TestResolveAutoDeclinesWithoutEvidence is the property that keeps +// StrategyAuto from wrecking precision. +// +// Falling back to "text" on a page with no rulings is what makes a naive +// lines->text fallback score WORSE than lines alone: measured on ICDAR +// 2013 it drops precision from 0.865 to 0.223, because prose has word +// alignment too and the text strategy will report a table for it. +// +// Rulings on the OTHER axis are the evidence that a table is really +// present. Without that evidence Auto must decline to guess. +func TestResolveAutoDeclinesWithoutEvidence(t *testing.T) { + // A prose page: no rulings anywhere. + if got := resolveAuto(StrategyAuto, layout.Vertical, nil); got != StrategyLines { + t.Errorf("no rulings resolved to %q; must stay %q so prose is not read as a table", + got, StrategyLines) + } + + // A single stray rule — an underline, a header separator, a page + // border — is not a table and must not count as evidence. + one := []layout.Edge{hEdge(0, 100, 200)} + if got := resolveAuto(StrategyAuto, layout.Vertical, one); got != StrategyLines { + t.Errorf("one stray rule resolved to %q; %d edges are needed before an axis counts as ruled", + got, minEdgesForAxis) + } +} + +// TestResolveAutoLeavesOtherStrategiesAlone guards the pass-through. +func TestResolveAutoLeavesOtherStrategiesAlone(t *testing.T) { + for _, s := range []TableStrategy{ + StrategyLines, StrategyLinesStrict, StrategyText, StrategyExplicit, + } { + if got := resolveAuto(s, layout.Vertical, nil); got != s { + t.Errorf("resolveAuto(%q) = %q, want it untouched", s, got) + } + } +} + +// TestAutoIsNotTheDefault pins that opting in is required. Auto improves +// table DETECTION but measurably lowers aggregate F1 on ICDAR 2013 +// (0.362 -> 0.358), so it must never be silently switched on. +func TestAutoIsNotTheDefault(t *testing.T) { + d := DefaultTableSettings() + if d.VerticalStrategy == StrategyAuto || d.HorizontalStrategy == StrategyAuto { + t.Error("StrategyAuto must not be a default — it lowers aggregate benchmark F1") + } +} diff --git a/table.go b/table.go index 1f3f9d9..8c1ee2d 100644 --- a/table.go +++ b/table.go @@ -58,6 +58,34 @@ const ( // The "explicit" strategy on an axis requires at least two // coordinates on that axis; fewer than two produces an error. StrategyExplicit TableStrategy = "explicit" + + // StrategyAuto picks "lines" or "text" for its axis by looking at + // what the page actually drew. It exists for the very common table + // that is ruled on ONE axis only. + // + // A table with horizontal rules and no vertical ones — booktabs + // style, and the house style of most government and academic + // publishing — yields no ruling intersections at all, so "lines" + // finds nothing on either axis. On the ICDAR 2013 competition set + // that accounted for every document where pdftable detected no + // table whatsoever: us-017 has 218 horizontal rules and 0 vertical, + // us-018 has 226 and 0, us-025 has 225 and 0. + // + // The rule, per axis: + // + // - this axis has usable rulings -> "lines" + // - it does not, but the OTHER axis does -> "text" + // - neither axis has rulings -> "lines" (find nothing) + // + // That last case is the important one. Falling back to "text" when + // the page has no rulings at all is what makes a naive lines->text + // fallback score WORSE than "lines" alone: on the same benchmark it + // drops precision from 0.865 to 0.223, because a prose page has + // word alignment too and the text strategy will happily report a + // table for it. Rulings on the other axis are the evidence that a + // table is really there; without that evidence Auto declines to + // guess. + StrategyAuto TableStrategy = "auto" ) // TableSettings controls table finding. Construct via