Skip to content

feat(table): detect table regions from text alignment, opt-in - #27

Merged
hallelx2 merged 1 commit into
mainfrom
halleluyaholudele/hal-1362-pdfgrab-port-nurminen-text-edge-detection
Sep 17, 2026
Merged

hallelx2 merged 1 commit into
mainfrom
halleluyaholudele/hal-1362-pdfgrab-port-nurminen-text-edge-detection

Conversation

@hallelx2

@hallelx2 hallelx2 commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Ports Anssi Nurminen's text-edge table detector — the algorithm that won the ICDAR 2013 structure track and that camelot's stream flavour runs. Opt-in, and a partial result.

Why

camelot reaches 0.762 recall where pdfgrab reaches 0.422. Reading its source explained it: _nurminen_table_detection(). We were running pdfplumber's text strategy, which was never a detector — it infers columns across the whole page with no notion of where a table is, which is why its precision is 0.188.

The idea: a table is not primarily a thing with lines around it, it is a region where text stays vertically aligned over several consecutive rows. Prose shares a left margin with the whole page but its interior positions wander. Find x-coordinates where ≥4 vertically adjacent lines start, end or centre together — that threshold is the entire method, and the filter prose cannot pass.

Measured — 125 docs, against the identical pipeline without it

precision recall F1 ms/doc
text, page-wide (control) 0.187 0.547 0.245 288
text + region detection 0.281 0.635 0.337 227

+38% relative F1, and faster — gridding a few bounded regions is less work than gridding a page.

It is half a success, and the half that failed is informative

Against camelot running the same algorithm:

recall precision
pdfgrab + detection 0.635 0.281
camelot stream 0.762 0.514

Recall transferred. Precision did not. Recall is a detection property; precision, once you hold a region, is a gridding property. So the detector works and our cell inference inside a known region is now the bottleneck — a different problem, and one the oracle result already bounds at 0.935.

Not the default

lines stays default at 0.442 vs this at 0.337. StrategyAuto is the precedent: it also found more regions and scored slightly worse, because regions found but gridded badly cost more precision than they buy in recall.

DetectRegions is worth enabling on corpora known to carry unruled tables — where lines returns nothing at all (22% of this corpus) and 0.337 beats zero.

Notes

  • Implemented independently from the published algorithm, not translated from camelot's MIT source.
  • Pure geometry over words already extracted — no model, no network, no new dependency, no CGo.
  • 12 unit tests including the two that matter: it finds an unruled table, and it refuses prose.
  • Both full benchmark runs agree to every decimal place across all 13 systems; the detector has its own determinism test.

Local: gofmt -l clean, go build, go vet, go test ./... green. CI red for HAL-1354 (no runner assigned account-wide).

Closes HAL-1362

Summary by Sourcery

Add opt-in text-alignment table-region detection to improve extraction of unruled tables while preserving the existing default strategy.

New Features:

  • Add opt-in table-region detection based on sustained text alignment, enabling discovery of unruled tables while avoiding page-wide prose grids.
  • Expose configurable text-edge detection settings through table extraction options and benchmark tooling.

Enhancements:

  • Apply text-derived edge inference within detected regions and fall back to existing page-wide behavior when detection is disabled or unavailable.
  • Add deterministic geometry-based detection for multiple regions, alignment types, gaps, rotated text, and prose rejection.

Documentation:

  • Document the detector's evaluation results, limitations, opt-in usage, and future gridding work.

Tests:

  • Add comprehensive unit coverage for unruled tables, prose rejection, alignment thresholds, separated tables, right-aligned columns, rotated text, tiny inputs, defaults, and determinism.

Chores:

  • Expand the ICDAR benchmark comparison from 10 to 13 systems and add detected-region configurations.

camelot reaches 0.762 recall on ICDAR 2013 where we reach 0.422, and
reading its source explained why: its stream flavour runs Anssi
Nurminen's text-edge detector, which won the 2013 competition. We were
running pdfplumber's text strategy, which was never a detector at all.

The idea is that a table is not primarily a thing with lines around it.
It is a region where text stays vertically aligned over several
consecutive rows. Prose does not do that — a paragraph shares a left
margin with the whole page, but its interior positions wander. So find
x-coordinates where four or more vertically adjacent lines start, end or
centre together, and their extent is the table.

That threshold is the entire method. It is the filter prose cannot pass,
and it is why this can raise recall without inventing tables the way
page-wide text derivation does.

Measured over 125 documents, against the identical pipeline without it:

  precision  0.187 -> 0.281
  recall     0.547 -> 0.635
  F1         0.245 -> 0.337        (+38% relative)
  ms/doc       288 -> 227

Faster as well as better, because gridding a few bounded regions is less
work than gridding a page.

It is NOT the default, and the result is only half a success. Against
camelot running the same algorithm, recall came across (0.635 vs 0.762)
and precision did not (0.281 vs 0.514). Recall is a detection property;
precision, once you hold a region, is a gridding property. So the
detector transferred and our cell inference inside a known region is now
the bottleneck — a different problem, and one the oracle result already
bounds at 0.935.

lines stays the default at 0.442. DetectRegions is worth enabling on
corpora known to carry unruled tables, where lines returns nothing at all
and 0.337 beats zero. StrategyAuto is the precedent for shipping a
detection change behind a flag until it is measured.

Implemented independently from the published algorithm, not translated
from camelot's MIT source. Pure geometry over words already extracted —
no model, no network, no new dependency, no CGo.

Both full benchmark runs agree to every decimal place across all 13
systems, and the detector carries its own determinism test.
Copilot AI lite review requested due to automatic review settings September 17, 2026 22:21
@sourcery-ai

sourcery-ai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR adds an opt-in, independently implemented Nurminen-style text-edge detector that bounds text-based table extraction to regions with sustained row alignment, integrates it without changing default behavior, and supplies tests, benchmark wiring, and evaluation documentation showing improved text-strategy results but unresolved precision limitations in cell gridding.

Sequence diagram for opt-in text-edge table extraction

sequenceDiagram
    participant Page
    participant Settings as TableSettings
    participant Detector as DetectTextEdgeRegions
    participant Extractor as baseEdges
    participant Grid as CellExtraction

    Page->>Settings: DetectRegions = true
    Page->>Detector: DetectTextEdgeRegions(words, TextEdge)
    Detector-->>Page: regions or nil
    alt regions detected
        Page->>Page: wordsInBBox(words, region)
        Page->>Extractor: baseEdges(text strategy, words in region)
        Extractor-->>Page: bounded vertical and horizontal edges
    else no regions or detection disabled
        Page->>Extractor: baseEdges(text strategy, page words)
        Extractor-->>Page: page-wide edges
    end
    Page->>Grid: infer cells from edges
Loading

Flow diagram for text-alignment region detection

flowchart TD
    A[Extracted words] --> B[wordsToTextLines]
    B --> C[Register line left, centre, and right coordinates]
    C --> D[buildTextEdges]
    D --> E{Coordinate match and vertical adjacency?}
    E -->|yes| F[Extend alignment edge]
    E -->|no| G[Start alignment edge]
    F --> H{At least 4 lines?}
    G --> H
    H -->|no| I[Discard alignment]
    H -->|yes| J[dominantAlignment]
    J --> K[regionsFromEdges]
    K --> L[Bounded table regions]
Loading

File-Level Changes

Change Details Files
Add an opt-in text-alignment detector that identifies bounded table regions from sustained vertical alignment in extracted words.
  • Group upright words into text lines and track left, center, and right alignment runs.
  • Require configurable adjacent-line evidence, select the dominant alignment class, and merge surviving edges into padded bounding boxes.
  • Handle empty, rotated, short, prose, separated-table, right-aligned, gap, zero-option, and determinism cases with unit tests.
detect_textedge.go
detect_textedge_test.go
Integrate detected regions into table edge derivation while preserving existing behavior by default.
  • Add DetectRegions and TextEdge settings, disabled by default with zero-value detector defaults.
  • Restrict text-derived edge computation to words inside detected regions and fall back to page-wide derivation when detection is disabled or finds nothing.
  • Keep line-based axes unaffected when no text strategy is active.
table.go
page.go
Expose the feature through the ICDAR benchmark harness and document its measured trade-offs.
  • Add a detector flag to the Go extractor and Python adapter, including text and fallback detection configurations plus a page-wide text control.
  • Record the 125-document comparison showing improved text-strategy F1 and recall but lower performance than the default lines strategy.
  • Document usage, reproducibility, determinism, limitations, and the remaining gridding bottleneck.
bench/icdar2013/extract.go
bench/icdar2013/systems.py
bench/README.md
docs/README.md
docs/evaluations/2026-09-17-text-edge-region-detection.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 26 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 30dce3b5-1926-4f6c-9b4d-1f8a8a03a369

📥 Commits

Reviewing files that changed from the base of the PR and between 0d9a99d and 95f2d73.

📒 Files selected for processing (9)
  • bench/README.md
  • bench/icdar2013/extract.go
  • bench/icdar2013/systems.py
  • detect_textedge.go
  • detect_textedge_test.go
  • docs/README.md
  • docs/evaluations/2026-09-17-text-edge-region-detection.md
  • page.go
  • table.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="detect_textedge.go" line_range="314-333" />
<code_context>
+	best := textAlign(0)
+	bestScore := -1
+
+	for a := textAlign(0); a < numAligns; a++ {
+		score := 0
+		for _, e := range edges[a] {
+			if e.count >= minLines {
+				score += e.count
+			}
+		}
+		if score > bestScore {
+			best, bestScore = a, score
</code_context>
<issue_to_address>
**issue (bug_risk):** A prose paragraph with four or more lines sharing the normal left margin produces a valid `alignLeft` edge and is returned as a table region, because the detector selects a single strongest alignment without requiring multiple independent column alignments. `wordsInBBox` then grids that prose region instead of falling back to page-wide detection.

**Triggers:** When ordinary body text contains at least four consecutive lines with the same left margin.

**Suggested fix:** Require evidence from at least two distinct alignments or add a rule that rejects a region supported only by a page-wide margin alignment.
</issue_to_address>

### Comment 2
<location path="detect_textedge.go" line_range="314-333" />
<code_context>
+	best := textAlign(0)
+	bestScore := -1
+
+	for a := textAlign(0); a < numAligns; a++ {
+		score := 0
+		for _, e := range edges[a] {
+			if e.count >= minLines {
+				score += e.count
+			}
+		}
+		if score > bestScore {
+			best, bestScore = a, score
</code_context>
<issue_to_address>
**issue (bug_risk):** Only one alignment class is retained for the entire page, so a page containing tables whose strongest evidence differs between left, center, and right alignment loses every table outside the globally dominant class. The detector consequently returns incomplete regions even though each individual table satisfies `MinLines`.

**Triggers:** When multiple tables on one page use different alignment styles and no single alignment class has the highest total count across the page.

**Suggested fix:** Select dominant alignment per candidate vertical band or retain valid alignment classes when their regions do not overlap.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread detect_textedge.go
Comment on lines +314 to +333
for a := textAlign(0); a < numAligns; a++ {
score := 0
for _, e := range edges[a] {
if e.count >= minLines {
score += e.count
}
}
if score > bestScore {
best, bestScore = a, score
}
}
if bestScore <= 0 {
return nil
}

valid := make([]*textEdge, 0, len(edges[best]))
for _, e := range edges[best] {
if e.count >= minLines {
valid = append(valid, e)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): A prose paragraph with four or more lines sharing the normal left margin produces a valid alignLeft edge and is returned as a table region, because the detector selects a single strongest alignment without requiring multiple independent column alignments. wordsInBBox then grids that prose region instead of falling back to page-wide detection.

Triggers: When ordinary body text contains at least four consecutive lines with the same left margin.

Suggested fix: Require evidence from at least two distinct alignments or add a rule that rejects a region supported only by a page-wide margin alignment.

Comment thread detect_textedge.go
Comment on lines +314 to +333
for a := textAlign(0); a < numAligns; a++ {
score := 0
for _, e := range edges[a] {
if e.count >= minLines {
score += e.count
}
}
if score > bestScore {
best, bestScore = a, score
}
}
if bestScore <= 0 {
return nil
}

valid := make([]*textEdge, 0, len(edges[best]))
for _, e := range edges[best] {
if e.count >= minLines {
valid = append(valid, e)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Only one alignment class is retained for the entire page, so a page containing tables whose strongest evidence differs between left, center, and right alignment loses every table outside the globally dominant class. The detector consequently returns incomplete regions even though each individual table satisfies MinLines.

Triggers: When multiple tables on one page use different alignment styles and no single alignment class has the highest total count across the page.

Suggested fix: Select dominant alignment per candidate vertical band or retain valid alignment classes when their regions do not overlap.

@hallelx2
hallelx2 merged commit 1d4beb9 into main Sep 17, 2026
2 of 5 checks passed
@hallelx2
hallelx2 deleted the halleluyaholudele/hal-1362-pdfgrab-port-nurminen-text-edge-detection branch September 17, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants