Skip to content

test: font-coverage corpus, and honour explicit spaces by default - #19

Merged
hallelx2 merged 1 commit into
mainfrom
halleluyaholudele/hal-511-font-coverage-corpus
Aug 2, 2026
Merged

test: font-coverage corpus, and honour explicit spaces by default#19
hallelx2 merged 1 commit into
mainfrom
halleluyaholudele/hal-511-font-coverage-corpus

Conversation

@hallelx2

@hallelx2 hallelx2 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

The suite ran on 26 words in a single font. That is how two font-metric bugs survived four releases. This adds a corpus that actually exercises the font layer — and fixes the first bug it found.

The fixtures

Hand-built rather than produced by a PDF library, on purpose. The standard-14 pages carry no /Widths and no /FontDescriptor — spec-legal, and exactly the case that hid both metric bugs. A reportlab fixture would embed the metrics and quietly test nothing. Three font sizes per page (8/12/24), because a descender scaled by the wrong factor is indistinguishable from a wrong constant at a single size.

They split by whether pdfplumber is a valid oracle:

where how it is checked
12 Latin standard fonts testdata/golden/ diffed against pdfplumber automatically
Symbol, ZapfDingbats, /Differences testdata/fonts/ asserted directly in Go

The second group gets no generated golden because pdfplumber is wrong there — it decodes Symbol with StandardEncoding and returns abgdep where the answer is αβγδεπ, and 123 where the answer is dingbats. Generating a golden would pin the wrong answer.

Coverage went from 26 words / 1 font to 170 words / 12 fonts / 3 sizes, and the 144 new words match pdfplumber to 0.01pt on both axes.

The bug it found

DefaultWordOpts() did not set UseExplicitSpaces. pdftable dropped space glyphs and re-inferred boundaries from the gap alone; pdfplumber ends a word at a whitespace glyph, before any gap test runs.

At 8pt the space is 278/1000 × 8 = 2.22pt — under the 3pt XTolerance — so a whole line collapsed:

pdftable    "Wimillegible3,142(16,048)"
pdfplumber  'Wim' 'illegible' '3,142' '(16,048)'

pdfplumber's own gap test would not have split either (90.22 > 87.99 + 3 is false), which is what proves it split on the space glyph. Body type in real documents is routinely 8–9pt, so this was not an edge case. Turning it on reproduces pdfplumber word-for-word and coordinate-for-coordinate.

Two follow-on flaws in MergeSplitTokens

Both found by re-running against a real filing, not by the unit tests:

  1. boundarySplitsToken measured gaps without excluding whitespace glyphs. A space is flush against its neighbours, so every space read as a zero-gap intra-word join and real words got welded together. Now a space in the gap settles it — but only in the gap: a space elsewhere in either cell says nothing about this boundary, and blocking on it stops every merge from happening at all (I made that mistake first).
  2. The join was unconditionally separator-less. Whether to merge is table-wide so the grid stays rectangular, but the separator has to be per-row — the same boundary can cut a token on one row and fall between two real words on another. (Dollars in millions, + except per share amount) became millions,except because a split several rows above had dropped that boundary.

Verification

  • go build, go vet, go test ./... -count=1 -race — green.
  • All pre-existing goldens unchanged.
  • 3M 2018 10-K: 103/103 negatives intact, grid rectangular, spacing correct.
  • TestStandard14FixtureCoversEveryLatinFont asserts the corpus breadth — 12 fonts, ≥3 sizes — so trimming the fixture cannot silently collapse coverage again.

Note on scope

gen_golden.py regenerates goldens for every PDF in the directory, including ones that had no committed golden before. I reverted those rather than silently rebaselining. One of them — word-level parity for issue-466-example — genuinely fails and is worth its own issue.

Closes HAL-511

Summary by Sourcery

Add a dedicated font-coverage fixture corpus and align default word extraction behaviour with pdfplumber to prevent over-merging of small-type text.

New Features:

  • Introduce script-generated font fixture PDFs covering all 12 Latin standard fonts plus Symbol, ZapfDingbats, and a /Differences-based Helvetica case for end-to-end font decoding tests.

Bug Fixes:

  • Ensure boundary-based token merging respects explicit space glyphs so real words are not incorrectly welded together across table cells.
  • Fix MergeSplitTokens to insert row-specific separators when merging tokens so table grids stay rectangular without corrupting word boundaries.
  • Enable UseExplicitSpaces in DefaultWordOpts so space glyphs reliably delimit words and small-type text no longer collapses into a single run.

Enhancements:

  • Add tests that validate correct decoding of Symbol, ZapfDingbats, and /Differences encodings and assert breadth of coverage across all Latin standard fonts and multiple sizes.
  • Document the new default behaviour and font-coverage corpus in the changelog for all maintained branches.

Summary by CodeRabbit

  • New Features
    • Improved text extraction by preserving explicit spaces and preventing words from being incorrectly merged across cell boundaries.
    • Added broader support validation for standard PDF fonts, Symbol, ZapfDingbats, and custom font differences.
  • Bug Fixes
    • Corrected missing or misplaced spaces in extracted text, including table content and small-font documents.
  • Tests
    • Added coverage across standard fonts, multiple font sizes, glyph encodings, and boundary-splitting scenarios.
  • Documentation
    • Updated release notes with font coverage metrics and extraction behavior details.

The suite ran on 26 words in a single font, which is how two font-metric
bugs survived four releases. Adds a corpus that actually exercises the
font layer, and fixes the first bug it found.

Fixtures are hand-built rather than produced by a PDF library, because
the point is to exercise what a producer normally hides: the standard-14
pages carry no /Widths and no /FontDescriptor, which is spec-legal and is
exactly the case that hid both metric bugs. A reportlab fixture would
embed the metrics and quietly test nothing. Three sizes per page, since a
descender scaled by the wrong factor is indistinguishable from a wrong
constant at a single size.

They split by whether pdfplumber is a valid oracle. The 12 Latin fonts go
to testdata/golden and are diffed against it automatically -- 144 words
now match to 0.01pt on both axes. Symbol, ZapfDingbats and /Differences go
to testdata/fonts with no generated golden and direct Go assertions
instead, because pdfplumber decodes Symbol with StandardEncoding and
returns "abgdep" where the answer is Greek. Generating a golden there
would pin the wrong answer.

The corpus immediately found a real divergence, which is the point.
DefaultWordOpts did not set UseExplicitSpaces, so pdftable dropped space
glyphs and re-inferred boundaries from the gap alone. pdfplumber ends a
word AT a whitespace glyph before any gap test runs. At 8pt the space is
2.22pt wide, under the 3pt XTolerance, so a whole line collapsed into one
run. Body type is routinely 8-9pt, so this was not exotic. Turning it on
reproduces pdfplumber word-for-word and coordinate-for-coordinate.

That change then exposed two flaws in MergeSplitTokens, both found by
re-running against a real filing rather than the unit tests:

  - boundarySplitsToken measured gaps without excluding whitespace
    glyphs. A space is flush against its neighbours, so every space read
    as a zero-gap intra-word join and real words were welded together.
    Now a space sitting in the gap settles the question outright -- but
    only in the gap, since a space elsewhere in either cell says nothing
    about this boundary and blocking on it stops every merge.
  - the join was unconditionally separator-less. Whether to merge is a
    table-wide decision so the grid stays rectangular, but the SEPARATOR
    has to be per-row: the same boundary can cut a token on one row and
    fall between two real words on another. "(Dollars in millions," and
    "except per share amount)" became "millions,except" because a split
    several rows above had dropped the boundary.

3M 2018 10-K re-checked: 103/103 negatives intact, grid rectangular,
spacing correct.

@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.

Sorry @hallelx2, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds generated font fixtures and extraction tests for standard PDF fonts. It enables explicit spaces by default and updates table-cell merging to preserve whitespace unless adjacent glyphs form one split token.

Changes

Font extraction and spacing

Layer / File(s) Summary
Font fixture generation and decoding coverage
scripts/gen_font_fixtures.py, fonts_fixture_test.go, testdata/golden/*
Adds generated PDFs, golden extraction results, and tests for Standard 14 Latin fonts, Symbol, ZapfDingbats, and /Differences encoding.
Explicit-space defaults and cell-boundary merging
text.go, page.go, merge_split_test.go, CHANGELOG.md
Enables UseExplicitSpaces by default. Cell merging preserves spaces unless whitespace is absent or the boundary forms one split token. Regression tests and changelog entries record the behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PDFFixture
  participant FontDecoder
  participant TextExtractor
  participant CellMerger
  PDFFixture->>FontDecoder: provide encoded glyphs
  FontDecoder->>TextExtractor: decode font glyphs
  TextExtractor->>CellMerger: pass extracted glyphs and spaces
  CellMerger-->>TextExtractor: preserve or merge boundary text
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the expanded font-coverage tests and the default handling of explicit spaces.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch halleluyaholudele/hal-511-font-coverage-corpus

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 commented Aug 2, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a hand-built font-coverage PDF corpus to exercise real font metrics and encodings, tightens word-boundary handling to respect explicit space glyphs, and refines merge/split token logic so table reconstruction honours per-row separators and whitespace, all verified against pdfplumber and custom fixtures.

Sequence diagram for updated mergeSplitTokens whitespace handling

sequenceDiagram
    participant TableExtractor
    participant mergeSplitTokens
    participant boundarySplitsToken

    TableExtractor->>mergeSplitTokens: mergeSplitTokens(rows, cells, chars, tol)
    loop eachRow
        loop eachCellBoundary
            mergeSplitTokens->>boundarySplitsToken: boundarySplitsToken(chars, prevCell, cell, tol)
            boundarySplitsToken-->>mergeSplitTokens: bool splits
            alt [joining cells]
                alt [text == "" or rowText[n] == ""]
                    mergeSplitTokens->>mergeSplitTokens: sep = ""
                else [boundarySplitsToken == true]
                    mergeSplitTokens->>mergeSplitTokens: sep = ""
                else [default]
                    mergeSplitTokens->>mergeSplitTokens: sep = " "
                end
                mergeSplitTokens->>mergeSplitTokens: rowText[n] += sep + text
            end
        end
    end
    mergeSplitTokens-->>TableExtractor: merged rows
Loading

File-Level Changes

Change Details Files
Default word extraction now honours explicit space glyphs via UseExplicitSpaces, aligning pdftable’s behaviour with pdfplumber and preventing over-merged words at small font sizes.
  • Set UseExplicitSpaces: true in DefaultWordOpts with rationale comments documenting the pdfplumber parity and small-type over-merge behaviour.
  • Documented the behaviour change in CHANGELOG across all Unreleased sections, explaining impact on word segmentation and coordinates.
text.go
CHANGELOG.md
Merge/split token logic for table extraction now treats separators as per-row decisions and ignores whitespace glyphs when measuring gaps, preventing unintended welding of distinct words and preserving rectangular grids.
  • Updated mergeSplitTokens to insert a conditional separator between merged cell text based on whether the boundary truly splits a token for that row.
  • Adjusted boundarySplitsToken to ignore whitespace glyphs when locating nearest ink on each side and to treat a space in the gap as a hard word boundary.
  • Added unit tests verifying that explicit spaces prevent token welding and that table grids remain rectangular with correct per-row separators.
page.go
merge_split_test.go
Introduced a hand-built font-coverage fixture generator and tests to validate decoding of Symbol, ZapfDingbats, /Differences, and coverage of all 12 Latin standard fonts across multiple sizes, with golden outputs where pdfplumber is a valid oracle.
  • Added scripts/gen_font_fixtures.py to produce minimal, uncompressed PDFs for Latin standard fonts, Symbol, ZapfDingbats, and a /Differences-based Helvetica fixture, intentionally omitting /Widths and /FontDescriptor for standard-14 fonts.
  • Created fonts_fixture_test.go with end-to-end tests that assert correct decoding of Symbol and ZapfDingbats, validate /Differences name resolution semantics, and ensure the standard14 fixture covers all 12 Latin fonts at at least three sizes.
  • Added new golden expectation files for the fonts-standard14 fixture to integrate with existing parity testing against pdfplumber.
scripts/gen_font_fixtures.py
fonts_fixture_test.go
testdata/golden/fonts-standard14.expected.json
testdata/golden/fonts-standard14.tables.expected.json
testdata/fonts/symbol.pdf
testdata/fonts/differences.pdf
testdata/golden/fonts-standard14.pdf

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
CHANGELOG.md (1)

10-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep one canonical Unreleased entry.

The same release note is repeated under seven ## [Unreleased] sections. Keep the note in one current release section and remove the duplicates. Repetition can produce duplicate release output and makes the changelog history ambiguous.

Also applies to: 104-118, 238-252, 397-411, 645-659, 1015-1029, 1427-1441

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 10 - 24, Keep the `DefaultWordOpts()`
behavior-change note in a single canonical `## [Unreleased]` changelog section,
and remove the duplicate copies from the other six `Unreleased` sections.
Preserve the note’s content in the retained current release section while
leaving unrelated historical entries unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@fonts_fixture_test.go`:
- Around line 165-169: Update the font-size validation around the sizes fixture
check to require the explicit presence of 8pt, 12pt, and 24pt, rather than only
checking that at least three sizes exist. Preserve the existing error-reporting
style and ensure the test fails if any required size is missing.

In `@page.go`:
- Around line 850-852: Update the whitespace classification in the
character-processing logic around c.Text so only characters with non-empty text
are checked with strings.TrimSpace and appended to spaces. Leave unmapped glyphs
with Text == "" out of spaces, preserving table-wide merge and separator
behavior.

---

Nitpick comments:
In `@CHANGELOG.md`:
- Around line 10-24: Keep the `DefaultWordOpts()` behavior-change note in a
single canonical `## [Unreleased]` changelog section, and remove the duplicate
copies from the other six `Unreleased` sections. Preserve the note’s content in
the retained current release section while leaving unrelated historical entries
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 748e8b19-c6fa-40bc-9405-763674cbef93

📥 Commits

Reviewing files that changed from the base of the PR and between be5bb9c and 97806d1.

⛔ Files ignored due to path filters (3)
  • testdata/fonts/differences.pdf is excluded by !**/*.pdf
  • testdata/fonts/symbol.pdf is excluded by !**/*.pdf
  • testdata/golden/fonts-standard14.pdf is excluded by !**/*.pdf
📒 Files selected for processing (8)
  • CHANGELOG.md
  • fonts_fixture_test.go
  • merge_split_test.go
  • page.go
  • scripts/gen_font_fixtures.py
  • testdata/golden/fonts-standard14.expected.json
  • testdata/golden/fonts-standard14.tables.expected.json
  • text.go

Comment thread fonts_fixture_test.go
Comment on lines +165 to +169
// Multiple sizes matter: a descent scaled by the wrong factor is
// indistinguishable from a wrong constant at a single size.
if len(sizes) < 3 {
t.Errorf("fixture uses %d font sizes, want >= 3", len(sizes))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the required font sizes.

The cardinality check passes if a future fixture removes 8pt and adds another size. That removes the small-space regression coverage. Check for 8pt, 12pt, and 24pt explicitly.

Proposed fix
-	if len(sizes) < 3 {
-		t.Errorf("fixture uses %d font sizes, want >= 3", len(sizes))
+	for _, size := range []float64{8, 12, 24} {
+		if !sizes[size] {
+			t.Errorf("fixture does not render the required %gpt size", size)
+		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Multiple sizes matter: a descent scaled by the wrong factor is
// indistinguishable from a wrong constant at a single size.
if len(sizes) < 3 {
t.Errorf("fixture uses %d font sizes, want >= 3", len(sizes))
}
// Multiple sizes matter: a descent scaled by the wrong factor is
// indistinguishable from a wrong constant at a single size.
for _, size := range []float64{8, 12, 24} {
if !sizes[size] {
t.Errorf("fixture does not render the required %gpt size", size)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fonts_fixture_test.go` around lines 165 - 169, Update the font-size
validation around the sizes fixture check to require the explicit presence of
8pt, 12pt, and 24pt, rather than only checking that at least three sizes exist.
Preserve the existing error-reporting style and ensure the test fails if any
required size is missing.

Comment thread page.go
Comment on lines +850 to +852
if strings.TrimSpace(c.Text) == "" {
spaces = append(spaces, c)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not classify unmapped glyphs as whitespace.

The Char contract defines Text == "" as a decoding failure, not as an explicit space. strings.TrimSpace("") == "", so Line 850 adds every unmapped glyph to spaces. This can suppress a valid table-wide merge or add an unwanted separator on a row that is already marked for merging.

Require non-empty text before the whitespace check.

Proposed fix
-		if strings.TrimSpace(c.Text) == "" {
+		if c.Text != "" && strings.TrimSpace(c.Text) == "" {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if strings.TrimSpace(c.Text) == "" {
spaces = append(spaces, c)
continue
if c.Text != "" && strings.TrimSpace(c.Text) == "" {
spaces = append(spaces, c)
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@page.go` around lines 850 - 852, Update the whitespace classification in the
character-processing logic around c.Text so only characters with non-empty text
are checked with strings.TrimSpace and appended to spaces. Leave unmapped glyphs
with Text == "" out of spaces, preserving table-wide merge and separator
behavior.

@hallelx2
hallelx2 merged commit 0ca65ca into main Aug 2, 2026
5 checks passed
@hallelx2
hallelx2 deleted the halleluyaholudele/hal-511-font-coverage-corpus branch August 2, 2026 20:57
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.

1 participant