From a873811b0cd9f565b1cf0c4b9f1235c20a6757b9 Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Thu, 20 Aug 2026 17:13:20 +0100 Subject: [PATCH 1/4] docs(changelog): rebuild the file from its last uncorrupted state CHANGELOG.md was 1866 lines holding 29 version headings for 8 versions: [Unreleased] six times, [0.3.1] six times, [0.3.0] five times. abe3742 (#17) is where it happened -- the file went 434 -> 1768 lines in one commit, because the edit prepended a new section plus the entire preceding file rather than merging into it, and every later edit repeated the pattern one level deeper. Each copy's final section was truncated where the next copy's preamble began, so the nesting also lost text: the MergeSplitTokens bullet ends mid-sentence at "a genuine column gutter -- the" followed by "# Changelog". Rebuilt from 7224c2c, the last commit with a clean file, with the released sections taken verbatim from there. The newer entries were recovered from the truncated copies and from the commits themselves. Two sections did not exist before and do now. 0.4.0 had no entry at all. It was tagged at 27d6c60 against a changelog whose newest heading was 0.3.1, so XToleranceRatio and UseExplicitSpaces shipped undocumented. Written from the commit. 0.5.0 collects the thirteen commits since that tag. It is a minor bump rather than a patch because DefaultWordOpts() now enables UseExplicitSpaces, which changes word segmentation for every existing caller -- called out under its own "Changed (behaviour)" heading rather than buried in the fixes. The ICDAR 2013 numbers and the 0.362 -> 0.935 oracle result are recorded there too, since the conclusion they support (the gap is table structure, not text extraction) is the reason the release exists in the shape it does. Version compare/tag links are restored for every version, including the two that had none. --- CHANGELOG.md | 1543 ++++---------------------------------------------- 1 file changed, 116 insertions(+), 1427 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 156a977..284caa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,1425 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Real font metrics for the 14 standard PDF fonts, plus a font-coverage -test corpus. One behaviour change to a default, noted below. +Nothing yet. -### Changed (behaviour) - -- **`DefaultWordOpts()` now enables `UseExplicitSpaces`.** pdfplumber's - `WordExtractor` ends a word *at* a whitespace glyph, before any gap - test runs; pdftable dropped spaces and re-inferred boundaries purely - from the gap. At small type that silently over-merges: an 8pt space is - 278/1000 x 8 = 2.22pt, under the 3pt `XTolerance`, so - `Wim illegible 3,142 (16,048)` came back as one run while pdfplumber - returned four words. Body type in real documents is routinely 8-9pt, so - this was not an edge case. With it on, pdftable matches pdfplumber - word-for-word and coordinate-for-coordinate across all 12 Latin - standard fonts at 8/12/24pt. - -### Fixed - -- fix: standard-14 fonts now resolve to their true Adobe AFM advance - widths. PDF 1.7 §9.6.2.2 lets those 14 fonts omit `/Widths` entirely, - on the assumption that a consumer already knows their metrics — - pdftable did not, so every glyph in such a font fell through to a flat - 500/1000 guess. `i` and `m` came out the same width, and the error - accumulated across a line into up to ~10pt of word-bbox drift. Since - the `text` and `lines_strict` table strategies infer column boundaries - from word positions, that drift could move a column split. Common - substitute names (`Arial`, `Times New Roman`, `Courier New`, subset - tags, case and whitespace variants) alias to their metric equivalents. - Narrow and Condensed variants deliberately do **not** match: they - share a family name but not the metrics, and returning regular-width - numbers would produce a confident, wrong bbox rather than an honest - fallback. -- fix: Symbol and ZapfDingbats are decoded with their own built-in - encodings instead of StandardEncoding. Symbol code `0x61` previously - decoded as `a` rather than `alpha`, so these fonts extracted as - mis-mapped Latin — a text-correctness bug, not only a metrics one. - Greek and the wider maths repertoire (`Alpha`, `universal`, `club`, …) - now resolve through the shared Adobe Glyph List path, which also fixes - `/Differences` arrays in ordinary fonts that name those glyphs. - ZapfDingbats' `aNN` names stay font-scoped on purpose: Adobe ships - them separately from the AGL because they are font-specific, and - resolving them globally would corrupt any font whose `/Differences` - happens to name `a1`. -- fix: all 14 standard fonts now resolve every AFM glyph to a distinct - rune (229 each for the Latin twelve, 190 Symbol, 202 ZapfDingbats). A - coverage test asserts those counts, so bundled-but-unreachable metrics - cannot recur silently. -- fix: glyph bounding boxes rest on the font's real descender. The - standard-14 exemption that permits omitting `/Widths` also permits - omitting `/FontDescriptor`, so `Ascent`/`Descent` were unavailable and - a glyph's box collapsed to `[baseline, baseline+size]`, sitting - `descent*size` too high — 2.484pt at 12pt text, 4.968pt at 24pt. A - second, independent bug compounded it: descent was scaled by 0.001 but - not by the font size, so even a font that *did* supply a descriptor got - a descender contribution short by a factor of the font size. Both are - fixed. This governs row detection — `lines_strict` and `text` infer row - boundaries from word Y extents — so it affected table structure, not - just reported coordinates. -- fix: a glyph straddling a table's outer edge is no longer discarded. - Cell assignment picks the cell containing a glyph's centre, which is - correct for an interior boundary but deletes content at the table's - own edge, where no competing cell exists. On a real 10-K balance sheet - the closing `)` of `(16,048)` sat 0.008pt beyond the last column and - was dropped, turning accounting notation for −16,048 into +16,048; - across five financial statements it flipped the sign of 19% of all - negative numbers while leaving every magnitude correct. - -### Changed - -- Golden position parity is now asserted at **0.01pt on both axes**, - down from a 15pt envelope. Measured drift against the fixtures is - exactly 0.0000pt. The old envelope was wide enough to pass with the - font-metric bugs fully present, so it could not have caught them. - -### Added - -- `TableSettings.MergeSplitTokens` (default **off**) merges two adjacent - cells when the column boundary between them falls inside a single - token. The `text` strategy derives columns by clustering word edges, so - a narrow band that happens to align down the page becomes a column even - when it cuts a value in half — a real 10-K balance sheet yields - `| ( | 16,135) |` for `(16,135)` and `| December 3 | 1, |` for - `December 31,`. Off by default because pdfplumber produces the same - splits (verified against pdfplumber 0.11.9), so enabling it silently - would break parity. Merging is bounded by `TextTolerance`, the same - threshold word grouping uses, so a genuine column gutter — the `# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -Real font metrics for the 14 standard PDF fonts, plus a font-coverage -test corpus. One behaviour change to a default, noted below. - -### Changed (behaviour) - -- **`DefaultWordOpts()` now enables `UseExplicitSpaces`.** pdfplumber's - `WordExtractor` ends a word *at* a whitespace glyph, before any gap - test runs; pdftable dropped spaces and re-inferred boundaries purely - from the gap. At small type that silently over-merges: an 8pt space is - 278/1000 x 8 = 2.22pt, under the 3pt `XTolerance`, so - `Wim illegible 3,142 (16,048)` came back as one run while pdfplumber - returned four words. Body type in real documents is routinely 8-9pt, so - this was not an edge case. With it on, pdftable matches pdfplumber - word-for-word and coordinate-for-coordinate across all 12 Latin - standard fonts at 8/12/24pt. - -### Fixed - -- fix: standard-14 fonts now resolve to their true Adobe AFM advance - widths. PDF 1.7 §9.6.2.2 lets those 14 fonts omit `/Widths` entirely, - on the assumption that a consumer already knows their metrics — - pdftable did not, so every glyph in such a font fell through to a flat - 500/1000 guess. `i` and `m` came out the same width, and the error - accumulated across a line into up to ~10pt of word-bbox drift. Since - the `text` and `lines_strict` table strategies infer column boundaries - from word positions, that drift could move a column split. Common - substitute names (`Arial`, `Times New Roman`, `Courier New`, subset - tags, case and whitespace variants) alias to their metric equivalents. - Narrow and Condensed variants deliberately do **not** match: they - share a family name but not the metrics, and returning regular-width - numbers would produce a confident, wrong bbox rather than an honest - fallback. -- fix: Symbol and ZapfDingbats are decoded with their own built-in - encodings instead of StandardEncoding. Symbol code `0x61` previously - decoded as `a` rather than `alpha`, so these fonts extracted as - mis-mapped Latin — a text-correctness bug, not only a metrics one. - Greek and the wider maths repertoire (`Alpha`, `universal`, `club`, …) - now resolve through the shared Adobe Glyph List path, which also fixes - `/Differences` arrays in ordinary fonts that name those glyphs. - ZapfDingbats' `aNN` names stay font-scoped on purpose: Adobe ships - them separately from the AGL because they are font-specific, and - resolving them globally would corrupt any font whose `/Differences` - happens to name `a1`. -- fix: all 14 standard fonts now resolve every AFM glyph to a distinct - rune (229 each for the Latin twelve, 190 Symbol, 202 ZapfDingbats). A - coverage test asserts those counts, so bundled-but-unreachable metrics - cannot recur silently. -- fix: glyph bounding boxes rest on the font's real descender. The - standard-14 exemption that permits omitting `/Widths` also permits - omitting `/FontDescriptor`, so `Ascent`/`Descent` were unavailable and - a glyph's box collapsed to `[baseline, baseline+size]`, sitting - `descent*size` too high — 2.484pt at 12pt text, 4.968pt at 24pt. A - second, independent bug compounded it: descent was scaled by 0.001 but - not by the font size, so even a font that *did* supply a descriptor got - a descender contribution short by a factor of the font size. Both are - fixed. This governs row detection — `lines_strict` and `text` infer row - boundaries from word Y extents — so it affected table structure, not - just reported coordinates. -- fix: a glyph straddling a table's outer edge is no longer discarded. - Cell assignment picks the cell containing a glyph's centre, which is - correct for an interior boundary but deletes content at the table's - own edge, where no competing cell exists. On a real 10-K balance sheet - the closing `)` of `(16,048)` sat 0.008pt beyond the last column and - was dropped, turning accounting notation for −16,048 into +16,048; - across five financial statements it flipped the sign of 19% of all - negative numbers while leaving every magnitude correct. - -### Changed - -- Golden position parity is now asserted at **0.01pt on both axes**, - down from a 15pt envelope. Measured drift against the fixtures is - exactly 0.0000pt. The old envelope was wide enough to pass with the - font-metric bugs fully present, so it could not have caught them. - - - column of a financial statement — is left alone. Cell bboxes are - merged too, which matters when they drive a citation highlight. -- `BBox.Viewport(pageHeight, scale)` and `BBox.Normalized(pageWidth, - pageHeight)` return a `ViewRect` in viewer coordinates (origin - top-left, Y down) for drawing citation highlights over a rendered - page. Every coordinate the package reports is already normalised — - MediaBox origin translated to (0,0) and `/Rotate` applied — so the - only conversion needed is the Y flip, now done once and tested. - -## [0.3.1] - 2026-05-29 - -Performance fix for the cell-finding stage. The v0.3.0 public API -surface is unchanged; v0.3.1 only adds two optional `TableSettings` -safety-cap fields (both default to "unset", so existing callers are -unaffected) and makes `FindTables` / `ExtractTables` dramatically -faster on densely-ruled pages. - -### Performance - -- perf: grid-indexed cell finding — `intersectionsToCells` goes from - O(n²/n³) to O(cells). Intersections lie on a lattice (unique X - positions × unique Y positions); the finder now indexes points into - that grid so each anchor locates its `below` / `right` candidates and - the closing corner in O(1) instead of rescanning the entire - intersection suffix. Dense financial pages (a fine ruling grid with - hundreds of rulings per axis → tens of thousands of intersections) - that previously hung for minutes now finish in milliseconds. On a - synthetic 200×200 lattice (40,401 intersections, 40,000 cells) the - cell finder drops from ~78 s to a few ms. The cell-selection order - (nearest-first outward walk, below-outer / right-inner, - first-close-wins, one cell per anchor) is preserved byte-for-byte, so - the emitted cell set is identical — the golden fixtures - (`issue-466-example`, `table-3x4-borderless`) produce the same tables - as before. -- perf: `edgesToIntersections` replaced its `V×H` pairwise scan with a - sweep — horizontal edges are sorted by Y and each vertical edge only - tests the band of horizontals whose Y lies within its span (located by - binary search). The intersection-tolerance semantics are unchanged. - -### Added - -- `TableSettings.MergeSplitTokens` (default **off**) merges two adjacent - cells when the column boundary between them falls inside a single - token. The `text` strategy derives columns by clustering word edges, so - a narrow band that happens to align down the page becomes a column even - when it cuts a value in half — a real 10-K balance sheet yields - `| ( | 16,135) |` for `(16,135)` and `| December 3 | 1, |` for - `December 31,`. Off by default because pdfplumber produces the same - splits (verified against pdfplumber 0.11.9), so enabling it silently - would break parity. Merging is bounded by `TextTolerance`, the same - threshold word grouping uses, so a genuine column gutter — the `# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -Real font metrics for the 14 standard PDF fonts, plus a font-coverage -test corpus. One behaviour change to a default, noted below. - -### Changed (behaviour) - -- **`DefaultWordOpts()` now enables `UseExplicitSpaces`.** pdfplumber's - `WordExtractor` ends a word *at* a whitespace glyph, before any gap - test runs; pdftable dropped spaces and re-inferred boundaries purely - from the gap. At small type that silently over-merges: an 8pt space is - 278/1000 x 8 = 2.22pt, under the 3pt `XTolerance`, so - `Wim illegible 3,142 (16,048)` came back as one run while pdfplumber - returned four words. Body type in real documents is routinely 8-9pt, so - this was not an edge case. With it on, pdftable matches pdfplumber - word-for-word and coordinate-for-coordinate across all 12 Latin - standard fonts at 8/12/24pt. - -### Fixed - -- fix: standard-14 fonts now resolve to their true Adobe AFM advance - widths. PDF 1.7 §9.6.2.2 lets those 14 fonts omit `/Widths` entirely, - on the assumption that a consumer already knows their metrics — - pdftable did not, so every glyph in such a font fell through to a flat - 500/1000 guess. `i` and `m` came out the same width, and the error - accumulated across a line into up to ~10pt of word-bbox drift. Since - the `text` and `lines_strict` table strategies infer column boundaries - from word positions, that drift could move a column split. Common - substitute names (`Arial`, `Times New Roman`, `Courier New`, subset - tags, case and whitespace variants) alias to their metric equivalents. - Narrow and Condensed variants deliberately do **not** match: they - share a family name but not the metrics, and returning regular-width - numbers would produce a confident, wrong bbox rather than an honest - fallback. -- fix: Symbol and ZapfDingbats are decoded with their own built-in - encodings instead of StandardEncoding. Symbol code `0x61` previously - decoded as `a` rather than `alpha`, so these fonts extracted as - mis-mapped Latin — a text-correctness bug, not only a metrics one. - Greek and the wider maths repertoire (`Alpha`, `universal`, `club`, …) - now resolve through the shared Adobe Glyph List path, which also fixes - `/Differences` arrays in ordinary fonts that name those glyphs. - ZapfDingbats' `aNN` names stay font-scoped on purpose: Adobe ships - them separately from the AGL because they are font-specific, and - resolving them globally would corrupt any font whose `/Differences` - happens to name `a1`. -- fix: all 14 standard fonts now resolve every AFM glyph to a distinct - rune (229 each for the Latin twelve, 190 Symbol, 202 ZapfDingbats). A - coverage test asserts those counts, so bundled-but-unreachable metrics - cannot recur silently. -- fix: glyph bounding boxes rest on the font's real descender. The - standard-14 exemption that permits omitting `/Widths` also permits - omitting `/FontDescriptor`, so `Ascent`/`Descent` were unavailable and - a glyph's box collapsed to `[baseline, baseline+size]`, sitting - `descent*size` too high — 2.484pt at 12pt text, 4.968pt at 24pt. A - second, independent bug compounded it: descent was scaled by 0.001 but - not by the font size, so even a font that *did* supply a descriptor got - a descender contribution short by a factor of the font size. Both are - fixed. This governs row detection — `lines_strict` and `text` infer row - boundaries from word Y extents — so it affected table structure, not - just reported coordinates. -- fix: a glyph straddling a table's outer edge is no longer discarded. - Cell assignment picks the cell containing a glyph's centre, which is - correct for an interior boundary but deletes content at the table's - own edge, where no competing cell exists. On a real 10-K balance sheet - the closing `)` of `(16,048)` sat 0.008pt beyond the last column and - was dropped, turning accounting notation for −16,048 into +16,048; - across five financial statements it flipped the sign of 19% of all - negative numbers while leaving every magnitude correct. - -### Changed - -- Golden position parity is now asserted at **0.01pt on both axes**, - down from a 15pt envelope. Measured drift against the fixtures is - exactly 0.0000pt. The old envelope was wide enough to pass with the - font-metric bugs fully present, so it could not have caught them. - -### Added - -- `BBox.Viewport(pageHeight, scale)` and `BBox.Normalized(pageWidth, - pageHeight)` return a `ViewRect` in viewer coordinates (origin - top-left, Y down) for drawing citation highlights over a rendered - page. Every coordinate the package reports is already normalised — - MediaBox origin translated to (0,0) and `/Rotate` applied — so the - only conversion needed is the Y flip, now done once and tested. - -## [0.3.1] - 2026-05-29 - -Performance fix for the cell-finding stage. The v0.3.0 public API -surface is unchanged; v0.3.1 only adds two optional `TableSettings` -safety-cap fields (both default to "unset", so existing callers are -unaffected) and makes `FindTables` / `ExtractTables` dramatically -faster on densely-ruled pages. - -### Performance - -- perf: grid-indexed cell finding — `intersectionsToCells` goes from - O(n²/n³) to O(cells). Intersections lie on a lattice (unique X - positions × unique Y positions); the finder now indexes points into - that grid so each anchor locates its `below` / `right` candidates and - the closing corner in O(1) instead of rescanning the entire - intersection suffix. Dense financial pages (a fine ruling grid with - hundreds of rulings per axis → tens of thousands of intersections) - that previously hung for minutes now finish in milliseconds. On a - synthetic 200×200 lattice (40,401 intersections, 40,000 cells) the - cell finder drops from ~78 s to a few ms. The cell-selection order - (nearest-first outward walk, below-outer / right-inner, - first-close-wins, one cell per anchor) is preserved byte-for-byte, so - the emitted cell set is identical — the golden fixtures - (`issue-466-example`, `table-3x4-borderless`) produce the same tables - as before. -- perf: `edgesToIntersections` replaced its `V×H` pairwise scan with a - sweep — horizontal edges are sorted by Y and each vertical edge only - tests the band of horizontals whose Y lies within its span (located by - binary search). The intersection-tolerance semantics are unchanged. - - - column of a financial statement — is left alone. Cell bboxes are - merged too, which matters when they drive a citation highlight. -- `TableSettings.MaxEdgesPerAxis` (default 1000) and - `TableSettings.MaxIntersections` (default 50000): defense-in-depth - caps. If a page yields more than `MaxEdgesPerAxis` vertical OR - horizontal edges after merging, or more than `MaxIntersections` edge - crossings, table finding is skipped for that page (no tables returned) - and a warning is logged. A real table never has this many rulings or - crossings on one axis; the caps bound the work even if some future - input defeats the grid optimization. Both treat zero as "unset" - (filled with the default) and a negative value as "disabled". -- `finder_bench_test.go`: `BenchmarkIntersectionsToCellsDenseGrid` and - `BenchmarkEdgesToIntersectionsDenseGrid` over a 200×200 lattice, plus - `TestDenseGridTerminatesQuickly` — a hard wall-clock assertion - (< 2 s for 200×200) that fails CI if the quadratic behaviour ever - returns. - -## [0.3.0] - 2026-05-27 - -Phase 1.3.D + 1.3.E — text and explicit table-finding strategies, the -`pdftable` CLI. Completes pdfplumber parity for the four canonical -table strategies. The v0.2.x public API surface is unchanged; v0.3.0 -only widens what's valid in `TableSettings` and adds the new CLI -binary, so existing callers compile and run as-is. - -### Added - -- `TableSettings.MergeSplitTokens` (default **off**) merges two adjacent - cells when the column boundary between them falls inside a single - token. The `text` strategy derives columns by clustering word edges, so - a narrow band that happens to align down the page becomes a column even - when it cuts a value in half — a real 10-K balance sheet yields - `| ( | 16,135) |` for `(16,135)` and `| December 3 | 1, |` for - `December 31,`. Off by default because pdfplumber produces the same - splits (verified against pdfplumber 0.11.9), so enabling it silently - would break parity. Merging is bounded by `TextTolerance`, the same - threshold word grouping uses, so a genuine column gutter — the `# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -Real font metrics for the 14 standard PDF fonts, plus a font-coverage -test corpus. One behaviour change to a default, noted below. - -### Changed (behaviour) - -- **`DefaultWordOpts()` now enables `UseExplicitSpaces`.** pdfplumber's - `WordExtractor` ends a word *at* a whitespace glyph, before any gap - test runs; pdftable dropped spaces and re-inferred boundaries purely - from the gap. At small type that silently over-merges: an 8pt space is - 278/1000 x 8 = 2.22pt, under the 3pt `XTolerance`, so - `Wim illegible 3,142 (16,048)` came back as one run while pdfplumber - returned four words. Body type in real documents is routinely 8-9pt, so - this was not an edge case. With it on, pdftable matches pdfplumber - word-for-word and coordinate-for-coordinate across all 12 Latin - standard fonts at 8/12/24pt. - -### Fixed - -- fix: standard-14 fonts now resolve to their true Adobe AFM advance - widths. PDF 1.7 §9.6.2.2 lets those 14 fonts omit `/Widths` entirely, - on the assumption that a consumer already knows their metrics — - pdftable did not, so every glyph in such a font fell through to a flat - 500/1000 guess. `i` and `m` came out the same width, and the error - accumulated across a line into up to ~10pt of word-bbox drift. Since - the `text` and `lines_strict` table strategies infer column boundaries - from word positions, that drift could move a column split. Common - substitute names (`Arial`, `Times New Roman`, `Courier New`, subset - tags, case and whitespace variants) alias to their metric equivalents. - Narrow and Condensed variants deliberately do **not** match: they - share a family name but not the metrics, and returning regular-width - numbers would produce a confident, wrong bbox rather than an honest - fallback. -- fix: Symbol and ZapfDingbats are decoded with their own built-in - encodings instead of StandardEncoding. Symbol code `0x61` previously - decoded as `a` rather than `alpha`, so these fonts extracted as - mis-mapped Latin — a text-correctness bug, not only a metrics one. - Greek and the wider maths repertoire (`Alpha`, `universal`, `club`, …) - now resolve through the shared Adobe Glyph List path, which also fixes - `/Differences` arrays in ordinary fonts that name those glyphs. - ZapfDingbats' `aNN` names stay font-scoped on purpose: Adobe ships - them separately from the AGL because they are font-specific, and - resolving them globally would corrupt any font whose `/Differences` - happens to name `a1`. -- fix: all 14 standard fonts now resolve every AFM glyph to a distinct - rune (229 each for the Latin twelve, 190 Symbol, 202 ZapfDingbats). A - coverage test asserts those counts, so bundled-but-unreachable metrics - cannot recur silently. -- fix: glyph bounding boxes rest on the font's real descender. The - standard-14 exemption that permits omitting `/Widths` also permits - omitting `/FontDescriptor`, so `Ascent`/`Descent` were unavailable and - a glyph's box collapsed to `[baseline, baseline+size]`, sitting - `descent*size` too high — 2.484pt at 12pt text, 4.968pt at 24pt. A - second, independent bug compounded it: descent was scaled by 0.001 but - not by the font size, so even a font that *did* supply a descriptor got - a descender contribution short by a factor of the font size. Both are - fixed. This governs row detection — `lines_strict` and `text` infer row - boundaries from word Y extents — so it affected table structure, not - just reported coordinates. -- fix: a glyph straddling a table's outer edge is no longer discarded. - Cell assignment picks the cell containing a glyph's centre, which is - correct for an interior boundary but deletes content at the table's - own edge, where no competing cell exists. On a real 10-K balance sheet - the closing `)` of `(16,048)` sat 0.008pt beyond the last column and - was dropped, turning accounting notation for −16,048 into +16,048; - across five financial statements it flipped the sign of 19% of all - negative numbers while leaving every magnitude correct. - -### Changed - -- Golden position parity is now asserted at **0.01pt on both axes**, - down from a 15pt envelope. Measured drift against the fixtures is - exactly 0.0000pt. The old envelope was wide enough to pass with the - font-metric bugs fully present, so it could not have caught them. - -### Added - -- `BBox.Viewport(pageHeight, scale)` and `BBox.Normalized(pageWidth, - pageHeight)` return a `ViewRect` in viewer coordinates (origin - top-left, Y down) for drawing citation highlights over a rendered - page. Every coordinate the package reports is already normalised — - MediaBox origin translated to (0,0) and `/Rotate` applied — so the - only conversion needed is the Y flip, now done once and tested. - -## [0.3.1] - 2026-05-29 - -Performance fix for the cell-finding stage. The v0.3.0 public API -surface is unchanged; v0.3.1 only adds two optional `TableSettings` -safety-cap fields (both default to "unset", so existing callers are -unaffected) and makes `FindTables` / `ExtractTables` dramatically -faster on densely-ruled pages. - -### Performance - -- perf: grid-indexed cell finding — `intersectionsToCells` goes from - O(n²/n³) to O(cells). Intersections lie on a lattice (unique X - positions × unique Y positions); the finder now indexes points into - that grid so each anchor locates its `below` / `right` candidates and - the closing corner in O(1) instead of rescanning the entire - intersection suffix. Dense financial pages (a fine ruling grid with - hundreds of rulings per axis → tens of thousands of intersections) - that previously hung for minutes now finish in milliseconds. On a - synthetic 200×200 lattice (40,401 intersections, 40,000 cells) the - cell finder drops from ~78 s to a few ms. The cell-selection order - (nearest-first outward walk, below-outer / right-inner, - first-close-wins, one cell per anchor) is preserved byte-for-byte, so - the emitted cell set is identical — the golden fixtures - (`issue-466-example`, `table-3x4-borderless`) produce the same tables - as before. -- perf: `edgesToIntersections` replaced its `V×H` pairwise scan with a - sweep — horizontal edges are sorted by Y and each vertical edge only - tests the band of horizontals whose Y lies within its span (located by - binary search). The intersection-tolerance semantics are unchanged. - -### Added - -- `TableSettings.MaxEdgesPerAxis` (default 1000) and - `TableSettings.MaxIntersections` (default 50000): defense-in-depth - caps. If a page yields more than `MaxEdgesPerAxis` vertical OR - horizontal edges after merging, or more than `MaxIntersections` edge - crossings, table finding is skipped for that page (no tables returned) - and a warning is logged. A real table never has this many rulings or - crossings on one axis; the caps bound the work even if some future - input defeats the grid optimization. Both treat zero as "unset" - (filled with the default) and a negative value as "disabled". -- `finder_bench_test.go`: `BenchmarkIntersectionsToCellsDenseGrid` and - `BenchmarkEdgesToIntersectionsDenseGrid` over a 200×200 lattice, plus - `TestDenseGridTerminatesQuickly` — a hard wall-clock assertion - (< 2 s for 200×200) that fails CI if the quadratic behaviour ever - returns. - -## [0.3.0] - 2026-05-27 - -Phase 1.3.D + 1.3.E — text and explicit table-finding strategies, the -`pdftable` CLI. Completes pdfplumber parity for the four canonical -table strategies. The v0.2.x public API surface is unchanged; v0.3.0 -only widens what's valid in `TableSettings` and adds the new CLI -binary, so existing callers compile and run as-is. - - - column of a financial statement — is left alone. Cell bboxes are - merged too, which matters when they drive a citation highlight. -- `StrategyText`: infer table edges from word alignment. Vertical - edges come from clusters of words sharing X0 (left), X1 (right), or - centre position with the per-axis tolerance hardcoded to 1 PDF - point (matching pdfplumber's `words_to_edges_v`). Horizontal edges - come from clusters sharing visual top, with both the top and - bottom of each cluster emitted so the last row gets captured - (matching `words_to_edges_h`). Threshold via - `TableSettings.MinWordsVertical` (default 3) and - `MinWordsHorizontal` (default 1). -- `StrategyExplicit`: caller-supplied edges via - `TableSettings.ExplicitVerticalLines` / - `ExplicitHorizontalLines`. When the strategy is `explicit` on an - axis, the supplied coordinates are the ONLY source of edges on - that axis; at least two coordinates are required (matching - pdfplumber's validation). Non-finite values (NaN, Inf) are skipped - with a `log` warning rather than crashing. -- Mixed strategies: every combination of the four strategies across - the two axes works (16 combinations total). The two axes' base - edges are derived independently then merged together for the - intersection pipeline — no orientation-specific logic leaks - between them. -- `pdftable` CLI binary at `cmd/pdftable/`. Subcommand surface - mirrors pdfplumber's: `extract [flags]` with - `--pages 1,3-5`, `--tables`, `--text`, `--format json|text`, - `--vertical-strategy`, `--horizontal-strategy`, the full set of - tolerance flags, `--min-words-vertical / horizontal`, - `--explicit-vertical-lines / horizontal-lines`, and `--indent`. - Stdlib `flag` package only — no third-party CLI dependencies. - Positional argument can appear before OR after flags - (pdfplumber-style invocation). Tested via - `cmd/pdftable/main_test.go` against the existing golden fixtures. -- New `layout.SourceText` enum value tagging edges produced by the - text strategy. `layout.SourceExplicit` was already in place from - v0.2.0; the explicit-strategy implementation now writes through - to it as the primary source. -- Hand-crafted borderless fixture `testdata.TableBorderless()` - (3-column × 4-row narrative table conveyed by whitespace alignment - only, no rules drawn). Used by the new text-strategy unit tests - and pdfplumber parity test. The generated PDF is in - `testdata/golden/table-3x4-borderless.pdf`. -- Golden-file parity test `TestGoldenTablesTextStrategyAgainstPdfplumber` - driven by `*.tables-text.expected.json` files. The - `table-3x4-borderless` fixture matches pdfplumber's - `find_tables({text, text})` cell-for-cell. Regenerate via the new - `scripts/capture_pdfplumber_text_golden.py` helper. -- `scripts/capture_pdfplumber_text_golden.py`: tiny Python helper - that captures pdfplumber's text-strategy output for every fixture - with a sibling `.tables-text.target` marker. Mirrors the existing - `scripts/gen_golden.py` workflow for the line-strategy goldens. - -### Changed - -- `Page.FindTables` / `Page.ExtractTables` no longer return - `ErrUnsupported` for `text` or `explicit` strategies — all four - strategies are now implemented. The error is still returned for - unknown strategy strings (typo guard). -- `TableSettings` field docs updated to reflect the implemented - semantics of `MinWordsVertical` / `MinWordsHorizontal` and the - Explicit*Lines slices. -- README's "Tables" section restructured: side-by-side - pdfplumber→pdftable examples for all four strategies, plus a - mixed-strategy snippet and a new "CLI" section. - -### Known limitations - -- Cell text fidelity on the text strategy depends on the same font - metrics as v0.2.x: PDFs that use standard-14 fonts without - bundled AFM tables can report intra-word gaps as zero, producing - cells like "Nohorizontal" where pdfplumber gets "No horizontal". - Structural parity (table count, row count, column count) matches - exactly; cell text matches verbatim on PDFs whose fonts have - bundled metrics or `/Widths` arrays. AFM-table bundling is a - v0.4.x goal. -- Mixed-strategy snap/join uses a single global tolerance. If a - page mixes drawn rules at one X coordinate and word-cluster - edges at a slightly different X, the two won't merge unless - `SnapTolerance` is widened. This matches pdfplumber's behaviour - but is worth noting for callers tuning a mixed pipeline. - -## [0.2.0] - 2026-05-27 - -Phase 1.3.C — table-finding via ruled lines. Direct port of -pdfplumber's `TableFinder` + cells-from-edges algorithm (`table.py`). -The v0.1.x public API surface is unchanged; v0.2.0 only adds methods -to the `Page` interface and new top-level types, so existing callers -compile and run as-is. - -### Added - -- `TableSettings.MergeSplitTokens` (default **off**) merges two adjacent - cells when the column boundary between them falls inside a single - token. The `text` strategy derives columns by clustering word edges, so - a narrow band that happens to align down the page becomes a column even - when it cuts a value in half — a real 10-K balance sheet yields - `| ( | 16,135) |` for `(16,135)` and `| December 3 | 1, |` for - `December 31,`. Off by default because pdfplumber produces the same - splits (verified against pdfplumber 0.11.9), so enabling it silently - would break parity. Merging is bounded by `TextTolerance`, the same - threshold word grouping uses, so a genuine column gutter — the `# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -Real font metrics for the 14 standard PDF fonts, plus a font-coverage -test corpus. One behaviour change to a default, noted below. - -### Changed (behaviour) - -- **`DefaultWordOpts()` now enables `UseExplicitSpaces`.** pdfplumber's - `WordExtractor` ends a word *at* a whitespace glyph, before any gap - test runs; pdftable dropped spaces and re-inferred boundaries purely - from the gap. At small type that silently over-merges: an 8pt space is - 278/1000 x 8 = 2.22pt, under the 3pt `XTolerance`, so - `Wim illegible 3,142 (16,048)` came back as one run while pdfplumber - returned four words. Body type in real documents is routinely 8-9pt, so - this was not an edge case. With it on, pdftable matches pdfplumber - word-for-word and coordinate-for-coordinate across all 12 Latin - standard fonts at 8/12/24pt. - -### Fixed - -- fix: standard-14 fonts now resolve to their true Adobe AFM advance - widths. PDF 1.7 §9.6.2.2 lets those 14 fonts omit `/Widths` entirely, - on the assumption that a consumer already knows their metrics — - pdftable did not, so every glyph in such a font fell through to a flat - 500/1000 guess. `i` and `m` came out the same width, and the error - accumulated across a line into up to ~10pt of word-bbox drift. Since - the `text` and `lines_strict` table strategies infer column boundaries - from word positions, that drift could move a column split. Common - substitute names (`Arial`, `Times New Roman`, `Courier New`, subset - tags, case and whitespace variants) alias to their metric equivalents. - Narrow and Condensed variants deliberately do **not** match: they - share a family name but not the metrics, and returning regular-width - numbers would produce a confident, wrong bbox rather than an honest - fallback. -- fix: Symbol and ZapfDingbats are decoded with their own built-in - encodings instead of StandardEncoding. Symbol code `0x61` previously - decoded as `a` rather than `alpha`, so these fonts extracted as - mis-mapped Latin — a text-correctness bug, not only a metrics one. - Greek and the wider maths repertoire (`Alpha`, `universal`, `club`, …) - now resolve through the shared Adobe Glyph List path, which also fixes - `/Differences` arrays in ordinary fonts that name those glyphs. - ZapfDingbats' `aNN` names stay font-scoped on purpose: Adobe ships - them separately from the AGL because they are font-specific, and - resolving them globally would corrupt any font whose `/Differences` - happens to name `a1`. -- fix: all 14 standard fonts now resolve every AFM glyph to a distinct - rune (229 each for the Latin twelve, 190 Symbol, 202 ZapfDingbats). A - coverage test asserts those counts, so bundled-but-unreachable metrics - cannot recur silently. -- fix: glyph bounding boxes rest on the font's real descender. The - standard-14 exemption that permits omitting `/Widths` also permits - omitting `/FontDescriptor`, so `Ascent`/`Descent` were unavailable and - a glyph's box collapsed to `[baseline, baseline+size]`, sitting - `descent*size` too high — 2.484pt at 12pt text, 4.968pt at 24pt. A - second, independent bug compounded it: descent was scaled by 0.001 but - not by the font size, so even a font that *did* supply a descriptor got - a descender contribution short by a factor of the font size. Both are - fixed. This governs row detection — `lines_strict` and `text` infer row - boundaries from word Y extents — so it affected table structure, not - just reported coordinates. -- fix: a glyph straddling a table's outer edge is no longer discarded. - Cell assignment picks the cell containing a glyph's centre, which is - correct for an interior boundary but deletes content at the table's - own edge, where no competing cell exists. On a real 10-K balance sheet - the closing `)` of `(16,048)` sat 0.008pt beyond the last column and - was dropped, turning accounting notation for −16,048 into +16,048; - across five financial statements it flipped the sign of 19% of all - negative numbers while leaving every magnitude correct. - -### Changed - -- Golden position parity is now asserted at **0.01pt on both axes**, - down from a 15pt envelope. Measured drift against the fixtures is - exactly 0.0000pt. The old envelope was wide enough to pass with the - font-metric bugs fully present, so it could not have caught them. - -### Added - -- `BBox.Viewport(pageHeight, scale)` and `BBox.Normalized(pageWidth, - pageHeight)` return a `ViewRect` in viewer coordinates (origin - top-left, Y down) for drawing citation highlights over a rendered - page. Every coordinate the package reports is already normalised — - MediaBox origin translated to (0,0) and `/Rotate` applied — so the - only conversion needed is the Y flip, now done once and tested. - -## [0.3.1] - 2026-05-29 - -Performance fix for the cell-finding stage. The v0.3.0 public API -surface is unchanged; v0.3.1 only adds two optional `TableSettings` -safety-cap fields (both default to "unset", so existing callers are -unaffected) and makes `FindTables` / `ExtractTables` dramatically -faster on densely-ruled pages. - -### Performance - -- perf: grid-indexed cell finding — `intersectionsToCells` goes from - O(n²/n³) to O(cells). Intersections lie on a lattice (unique X - positions × unique Y positions); the finder now indexes points into - that grid so each anchor locates its `below` / `right` candidates and - the closing corner in O(1) instead of rescanning the entire - intersection suffix. Dense financial pages (a fine ruling grid with - hundreds of rulings per axis → tens of thousands of intersections) - that previously hung for minutes now finish in milliseconds. On a - synthetic 200×200 lattice (40,401 intersections, 40,000 cells) the - cell finder drops from ~78 s to a few ms. The cell-selection order - (nearest-first outward walk, below-outer / right-inner, - first-close-wins, one cell per anchor) is preserved byte-for-byte, so - the emitted cell set is identical — the golden fixtures - (`issue-466-example`, `table-3x4-borderless`) produce the same tables - as before. -- perf: `edgesToIntersections` replaced its `V×H` pairwise scan with a - sweep — horizontal edges are sorted by Y and each vertical edge only - tests the band of horizontals whose Y lies within its span (located by - binary search). The intersection-tolerance semantics are unchanged. - -### Added - -- `TableSettings.MaxEdgesPerAxis` (default 1000) and - `TableSettings.MaxIntersections` (default 50000): defense-in-depth - caps. If a page yields more than `MaxEdgesPerAxis` vertical OR - horizontal edges after merging, or more than `MaxIntersections` edge - crossings, table finding is skipped for that page (no tables returned) - and a warning is logged. A real table never has this many rulings or - crossings on one axis; the caps bound the work even if some future - input defeats the grid optimization. Both treat zero as "unset" - (filled with the default) and a negative value as "disabled". -- `finder_bench_test.go`: `BenchmarkIntersectionsToCellsDenseGrid` and - `BenchmarkEdgesToIntersectionsDenseGrid` over a 200×200 lattice, plus - `TestDenseGridTerminatesQuickly` — a hard wall-clock assertion - (< 2 s for 200×200) that fails CI if the quadratic behaviour ever - returns. - -## [0.3.0] - 2026-05-27 - -Phase 1.3.D + 1.3.E — text and explicit table-finding strategies, the -`pdftable` CLI. Completes pdfplumber parity for the four canonical -table strategies. The v0.2.x public API surface is unchanged; v0.3.0 -only widens what's valid in `TableSettings` and adds the new CLI -binary, so existing callers compile and run as-is. - -### Added - -- `StrategyText`: infer table edges from word alignment. Vertical - edges come from clusters of words sharing X0 (left), X1 (right), or - centre position with the per-axis tolerance hardcoded to 1 PDF - point (matching pdfplumber's `words_to_edges_v`). Horizontal edges - come from clusters sharing visual top, with both the top and - bottom of each cluster emitted so the last row gets captured - (matching `words_to_edges_h`). Threshold via - `TableSettings.MinWordsVertical` (default 3) and - `MinWordsHorizontal` (default 1). -- `StrategyExplicit`: caller-supplied edges via - `TableSettings.ExplicitVerticalLines` / - `ExplicitHorizontalLines`. When the strategy is `explicit` on an - axis, the supplied coordinates are the ONLY source of edges on - that axis; at least two coordinates are required (matching - pdfplumber's validation). Non-finite values (NaN, Inf) are skipped - with a `log` warning rather than crashing. -- Mixed strategies: every combination of the four strategies across - the two axes works (16 combinations total). The two axes' base - edges are derived independently then merged together for the - intersection pipeline — no orientation-specific logic leaks - between them. -- `pdftable` CLI binary at `cmd/pdftable/`. Subcommand surface - mirrors pdfplumber's: `extract [flags]` with - `--pages 1,3-5`, `--tables`, `--text`, `--format json|text`, - `--vertical-strategy`, `--horizontal-strategy`, the full set of - tolerance flags, `--min-words-vertical / horizontal`, - `--explicit-vertical-lines / horizontal-lines`, and `--indent`. - Stdlib `flag` package only — no third-party CLI dependencies. - Positional argument can appear before OR after flags - (pdfplumber-style invocation). Tested via - `cmd/pdftable/main_test.go` against the existing golden fixtures. -- New `layout.SourceText` enum value tagging edges produced by the - text strategy. `layout.SourceExplicit` was already in place from - v0.2.0; the explicit-strategy implementation now writes through - to it as the primary source. -- Hand-crafted borderless fixture `testdata.TableBorderless()` - (3-column × 4-row narrative table conveyed by whitespace alignment - only, no rules drawn). Used by the new text-strategy unit tests - and pdfplumber parity test. The generated PDF is in - `testdata/golden/table-3x4-borderless.pdf`. -- Golden-file parity test `TestGoldenTablesTextStrategyAgainstPdfplumber` - driven by `*.tables-text.expected.json` files. The - `table-3x4-borderless` fixture matches pdfplumber's - `find_tables({text, text})` cell-for-cell. Regenerate via the new - `scripts/capture_pdfplumber_text_golden.py` helper. -- `scripts/capture_pdfplumber_text_golden.py`: tiny Python helper - that captures pdfplumber's text-strategy output for every fixture - with a sibling `.tables-text.target` marker. Mirrors the existing - `scripts/gen_golden.py` workflow for the line-strategy goldens. - -### Changed - -- `Page.FindTables` / `Page.ExtractTables` no longer return - `ErrUnsupported` for `text` or `explicit` strategies — all four - strategies are now implemented. The error is still returned for - unknown strategy strings (typo guard). -- `TableSettings` field docs updated to reflect the implemented - semantics of `MinWordsVertical` / `MinWordsHorizontal` and the - Explicit*Lines slices. -- README's "Tables" section restructured: side-by-side - pdfplumber→pdftable examples for all four strategies, plus a - mixed-strategy snippet and a new "CLI" section. - -### Known limitations - -- Cell text fidelity on the text strategy depends on the same font - metrics as v0.2.x: PDFs that use standard-14 fonts without - bundled AFM tables can report intra-word gaps as zero, producing - cells like "Nohorizontal" where pdfplumber gets "No horizontal". - Structural parity (table count, row count, column count) matches - exactly; cell text matches verbatim on PDFs whose fonts have - bundled metrics or `/Widths` arrays. AFM-table bundling is a - v0.4.x goal. -- Mixed-strategy snap/join uses a single global tolerance. If a - page mixes drawn rules at one X coordinate and word-cluster - edges at a slightly different X, the two won't merge unless - `SnapTolerance` is widened. This matches pdfplumber's behaviour - but is worth noting for callers tuning a mixed pipeline. - -## [0.2.0] - 2026-05-27 - -Phase 1.3.C — table-finding via ruled lines. Direct port of -pdfplumber's `TableFinder` + cells-from-edges algorithm (`table.py`). -The v0.1.x public API surface is unchanged; v0.2.0 only adds methods -to the `Page` interface and new top-level types, so existing callers -compile and run as-is. - - - column of a financial statement — is left alone. Cell bboxes are - merged too, which matters when they drive a citation highlight. -- `Page.FindTables(settings TableSettings) ([]TableFinder, error)` — - geometry-only stage of the pipeline. Returns one TableFinder per - detected table group with the merged edges, intersections, raw - cells, and assembled per-table CellsGrid exposed for debugging / - custom rendering. -- `Page.ExtractTables(settings TableSettings) ([]*Table, error)` — - wraps FindTables, runs per-cell text extraction, returns fully - populated `Table` structs. Cell text is the dense extract\_text - output for chars whose centre point falls inside the cell bbox, - with leading / trailing whitespace stripped. Empty cells produce - `""`. -- `TableSettings` struct with `DefaultTableSettings()` constructor - carrying pdfplumber-matching defaults (snap\_tolerance=3, - join\_tolerance=3, edge\_min\_length=3, edge\_min\_length\_prefilter=1, - intersection\_tolerance=3, text\_tolerance=3). -- `TableStrategy` enum with constants `StrategyLines`, - `StrategyLinesStrict`, `StrategyText`, `StrategyExplicit`. Only - `StrategyLines` and `StrategyLinesStrict` are implemented in this - release; `StrategyText` and `StrategyExplicit` are deferred to - v0.3.0 and return `ErrUnsupported` (with a clear "Phase 1.3.D" - message) so callers don't get silent empty results. -- `Table` (rows × columns of cell text + bbox + per-cell bbox grid), - `TableFinder` (edges + intersections + cells + tables), `TableBox` - (one assembled table's geometry: bbox + Rows × Cols grid), - `Intersection` (one edge-crossing point with its participating - vertical and horizontal edges). -- Internal `internal/layout` package: `Edge` type with `FromLine`, - `FromRect`, `FromCurve` constructors, plus the snap → join → - filter pipeline (`SnapEdges`, `JoinEdges`, `MergeEdges`, - `FilterEdgesByLength`, `FilterEdgesBySource`, - `FilterEdgesByOrientation`, `SortEdges`). -- Golden-file parity test against pdfplumber's `find_tables({"lines"})` - on the `issue-466-example.pdf` fixture (4×3 + 2×3 ruled tables). - Test infrastructure (`TestGoldenTablesAgainstPdfplumber` in - `golden_test.go`) loads any `*.tables.expected.json` fixture in - `testdata/golden/` and compares cell-for-cell after whitespace - normalisation. Regenerate via `python scripts/gen_golden.py`. -- New hand-crafted fixture: `testdata.TableRuled()` — minimal - 2-column × 3-row ruled table with predictable text ("Name", "Age"; - "Alice", "30"; "Bob", "25") for unit testing the public API - surface without depending on third-party PDFs. Generator script - at `scripts/gen_table_fixture.go`. -- Algorithm-level unit tests in `table_test.go`: hand-crafted edge - lists exercising `edgesToIntersections`, `intersectionsToCells`, - `cellsToTables`, `assembleTableBox`, and the full `runTableFinder` - pipeline. -- README "Tables" section with a side-by-side Go / pdfplumber - example. The example is also extracted as a runnable program at - `examples/extract_tables/main.go` so changes to the API surface - break the example at build time. - -### Deferred (planned for v0.3.0 — Phase 1.3.D) - -- `StrategyText`: infer table edges from word alignment (clusters of - words sharing x0 / x1 / centre, clusters of words sharing top / - bottom). Useful for PDFs whose tables have no ruled lines (e.g. - banking statements, scanned-then-OCR'd documents). -- `StrategyExplicit`: caller-supplied edges via - `TableSettings.ExplicitVerticalLines` / - `ExplicitHorizontalLines`. In v0.2.0 these settings are accepted - and added on top of the derived edges (helpful when a column - boundary isn't drawn), but they don't form the only source of - edges yet. - -### Known limitations - -- The cell-text extraction shares the v0.1.x word-grouping engine, - which depends on font metrics. Cells whose glyphs use standard-14 - fonts WITHOUT the bundled AFM tables can have intra-word gaps - reported as "no gap" — e.g. "Hello World" comes out as - "HelloWorld". This was already documented for v0.1.0; for v0.2.0 - it means the parity test against - `la-precinct-bulletin-2014-p1.pdf` (which uses Helvetica-Bold) - fails on cell text equality. The fixture is not checked in to - avoid CI noise; it'll be re-added once the AFM bundle lands in - v0.2.x. -- `senate-expenditures.pdf` produces 7 cells where pdfplumber finds - 10. The divergence is in how snap+join unifies edges that share a - near-collinear endpoint but differ slightly in the perpendicular - axis; under investigation as a follow-up issue. The fixture is - not in the golden set yet. - -## [0.1.1] - 2026-05-27 - -### Fixed - -- StandardEncoding, WinAnsiEncoding, MacRomanEncoding, and - PDFDocEncoding are now driven from a single source of truth - (`encodingRows`) that mirrors pdfminer.six's `latin_enc.py` and PDF - Reference 1.7 Appendix D.2. The previous tables silently dropped - ~32 named glyphs per encoding outside printable ASCII — most - visibly the smart quotes (`’ ‘ “ ”`), en/em dashes (`– —`), - bullet (`•`), florin (`ƒ`), and dagger marks (`† ‡`). PDFs that - used these without a `/ToUnicode` map (the common case for PDF/A - filings, SEC 10-Ks, and most LaTeX-emitted documents) returned - empty or garbled text where these glyphs appeared. -- `AdobeGlyphToUnicode` now resolves the full Adobe Glyph List for - common Latin/typographic glyphs (~250 entries) instead of a minimal - ~30-entry table. Added support for AGL §2 compound names (`f_i` - decomposes to `fi`) and variant suffixes (`.alt`, `.sc` are - stripped before lookup). -- StandardEncoding now correctly maps slot 0x27 to `quoteright` - (`’`, U+2019) and 0x60 to `quoteleft` (`‘`, U+2018), matching the - PDF spec. WinAnsi/MacRoman/PDFDoc keep ASCII `'` and `` ` `` at - those slots, as the spec requires. - -### Note - -This is a behavior change for callers that depended on the pre-v0.1.1 -ASCII-identity behavior of StandardEncoding at 0x27 / 0x60. The new -behavior is spec-correct and matches what pdfplumber, pdfminer.six, -and Ghostscript emit for the same input. - -## [0.1.0] - 2026-05-26 - -Phase 1.3.B — words and text extraction. Direct port of pdfplumber's -`WordExtractor`, `extract_text`, `extract_text_simple`. The v0.0.1 -public API surface is unchanged; v0.1.0 only adds methods to the -`Page` interface, so existing callers compile and run as-is. - -### Added - -- `TableSettings.MergeSplitTokens` (default **off**) merges two adjacent - cells when the column boundary between them falls inside a single - token. The `text` strategy derives columns by clustering word edges, so - a narrow band that happens to align down the page becomes a column even - when it cuts a value in half — a real 10-K balance sheet yields - `| ( | 16,135) |` for `(16,135)` and `| December 3 | 1, |` for - `December 31,`. Off by default because pdfplumber produces the same - splits (verified against pdfplumber 0.11.9), so enabling it silently - would break parity. Merging is bounded by `TextTolerance`, the same - threshold word grouping uses, so a genuine column gutter — the `# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -Real font metrics for the 14 standard PDF fonts, plus a font-coverage -test corpus. One behaviour change to a default, noted below. - -### Changed (behaviour) - -- **`DefaultWordOpts()` now enables `UseExplicitSpaces`.** pdfplumber's - `WordExtractor` ends a word *at* a whitespace glyph, before any gap - test runs; pdftable dropped spaces and re-inferred boundaries purely - from the gap. At small type that silently over-merges: an 8pt space is - 278/1000 x 8 = 2.22pt, under the 3pt `XTolerance`, so - `Wim illegible 3,142 (16,048)` came back as one run while pdfplumber - returned four words. Body type in real documents is routinely 8-9pt, so - this was not an edge case. With it on, pdftable matches pdfplumber - word-for-word and coordinate-for-coordinate across all 12 Latin - standard fonts at 8/12/24pt. - -### Fixed - -- fix: standard-14 fonts now resolve to their true Adobe AFM advance - widths. PDF 1.7 §9.6.2.2 lets those 14 fonts omit `/Widths` entirely, - on the assumption that a consumer already knows their metrics — - pdftable did not, so every glyph in such a font fell through to a flat - 500/1000 guess. `i` and `m` came out the same width, and the error - accumulated across a line into up to ~10pt of word-bbox drift. Since - the `text` and `lines_strict` table strategies infer column boundaries - from word positions, that drift could move a column split. Common - substitute names (`Arial`, `Times New Roman`, `Courier New`, subset - tags, case and whitespace variants) alias to their metric equivalents. - Narrow and Condensed variants deliberately do **not** match: they - share a family name but not the metrics, and returning regular-width - numbers would produce a confident, wrong bbox rather than an honest - fallback. -- fix: Symbol and ZapfDingbats are decoded with their own built-in - encodings instead of StandardEncoding. Symbol code `0x61` previously - decoded as `a` rather than `alpha`, so these fonts extracted as - mis-mapped Latin — a text-correctness bug, not only a metrics one. - Greek and the wider maths repertoire (`Alpha`, `universal`, `club`, …) - now resolve through the shared Adobe Glyph List path, which also fixes - `/Differences` arrays in ordinary fonts that name those glyphs. - ZapfDingbats' `aNN` names stay font-scoped on purpose: Adobe ships - them separately from the AGL because they are font-specific, and - resolving them globally would corrupt any font whose `/Differences` - happens to name `a1`. -- fix: all 14 standard fonts now resolve every AFM glyph to a distinct - rune (229 each for the Latin twelve, 190 Symbol, 202 ZapfDingbats). A - coverage test asserts those counts, so bundled-but-unreachable metrics - cannot recur silently. -- fix: glyph bounding boxes rest on the font's real descender. The - standard-14 exemption that permits omitting `/Widths` also permits - omitting `/FontDescriptor`, so `Ascent`/`Descent` were unavailable and - a glyph's box collapsed to `[baseline, baseline+size]`, sitting - `descent*size` too high — 2.484pt at 12pt text, 4.968pt at 24pt. A - second, independent bug compounded it: descent was scaled by 0.001 but - not by the font size, so even a font that *did* supply a descriptor got - a descender contribution short by a factor of the font size. Both are - fixed. This governs row detection — `lines_strict` and `text` infer row - boundaries from word Y extents — so it affected table structure, not - just reported coordinates. -- fix: a glyph straddling a table's outer edge is no longer discarded. - Cell assignment picks the cell containing a glyph's centre, which is - correct for an interior boundary but deletes content at the table's - own edge, where no competing cell exists. On a real 10-K balance sheet - the closing `)` of `(16,048)` sat 0.008pt beyond the last column and - was dropped, turning accounting notation for −16,048 into +16,048; - across five financial statements it flipped the sign of 19% of all - negative numbers while leaving every magnitude correct. - -### Changed - -- Golden position parity is now asserted at **0.01pt on both axes**, - down from a 15pt envelope. Measured drift against the fixtures is - exactly 0.0000pt. The old envelope was wide enough to pass with the - font-metric bugs fully present, so it could not have caught them. - -### Added - -- `BBox.Viewport(pageHeight, scale)` and `BBox.Normalized(pageWidth, - pageHeight)` return a `ViewRect` in viewer coordinates (origin - top-left, Y down) for drawing citation highlights over a rendered - page. Every coordinate the package reports is already normalised — - MediaBox origin translated to (0,0) and `/Rotate` applied — so the - only conversion needed is the Y flip, now done once and tested. - -## [0.3.1] - 2026-05-29 - -Performance fix for the cell-finding stage. The v0.3.0 public API -surface is unchanged; v0.3.1 only adds two optional `TableSettings` -safety-cap fields (both default to "unset", so existing callers are -unaffected) and makes `FindTables` / `ExtractTables` dramatically -faster on densely-ruled pages. - -### Performance - -- perf: grid-indexed cell finding — `intersectionsToCells` goes from - O(n²/n³) to O(cells). Intersections lie on a lattice (unique X - positions × unique Y positions); the finder now indexes points into - that grid so each anchor locates its `below` / `right` candidates and - the closing corner in O(1) instead of rescanning the entire - intersection suffix. Dense financial pages (a fine ruling grid with - hundreds of rulings per axis → tens of thousands of intersections) - that previously hung for minutes now finish in milliseconds. On a - synthetic 200×200 lattice (40,401 intersections, 40,000 cells) the - cell finder drops from ~78 s to a few ms. The cell-selection order - (nearest-first outward walk, below-outer / right-inner, - first-close-wins, one cell per anchor) is preserved byte-for-byte, so - the emitted cell set is identical — the golden fixtures - (`issue-466-example`, `table-3x4-borderless`) produce the same tables - as before. -- perf: `edgesToIntersections` replaced its `V×H` pairwise scan with a - sweep — horizontal edges are sorted by Y and each vertical edge only - tests the band of horizontals whose Y lies within its span (located by - binary search). The intersection-tolerance semantics are unchanged. - -### Added - -- `TableSettings.MaxEdgesPerAxis` (default 1000) and - `TableSettings.MaxIntersections` (default 50000): defense-in-depth - caps. If a page yields more than `MaxEdgesPerAxis` vertical OR - horizontal edges after merging, or more than `MaxIntersections` edge - crossings, table finding is skipped for that page (no tables returned) - and a warning is logged. A real table never has this many rulings or - crossings on one axis; the caps bound the work even if some future - input defeats the grid optimization. Both treat zero as "unset" - (filled with the default) and a negative value as "disabled". -- `finder_bench_test.go`: `BenchmarkIntersectionsToCellsDenseGrid` and - `BenchmarkEdgesToIntersectionsDenseGrid` over a 200×200 lattice, plus - `TestDenseGridTerminatesQuickly` — a hard wall-clock assertion - (< 2 s for 200×200) that fails CI if the quadratic behaviour ever - returns. - -## [0.3.0] - 2026-05-27 - -Phase 1.3.D + 1.3.E — text and explicit table-finding strategies, the -`pdftable` CLI. Completes pdfplumber parity for the four canonical -table strategies. The v0.2.x public API surface is unchanged; v0.3.0 -only widens what's valid in `TableSettings` and adds the new CLI -binary, so existing callers compile and run as-is. - -### Added - -- `StrategyText`: infer table edges from word alignment. Vertical - edges come from clusters of words sharing X0 (left), X1 (right), or - centre position with the per-axis tolerance hardcoded to 1 PDF - point (matching pdfplumber's `words_to_edges_v`). Horizontal edges - come from clusters sharing visual top, with both the top and - bottom of each cluster emitted so the last row gets captured - (matching `words_to_edges_h`). Threshold via - `TableSettings.MinWordsVertical` (default 3) and - `MinWordsHorizontal` (default 1). -- `StrategyExplicit`: caller-supplied edges via - `TableSettings.ExplicitVerticalLines` / - `ExplicitHorizontalLines`. When the strategy is `explicit` on an - axis, the supplied coordinates are the ONLY source of edges on - that axis; at least two coordinates are required (matching - pdfplumber's validation). Non-finite values (NaN, Inf) are skipped - with a `log` warning rather than crashing. -- Mixed strategies: every combination of the four strategies across - the two axes works (16 combinations total). The two axes' base - edges are derived independently then merged together for the - intersection pipeline — no orientation-specific logic leaks - between them. -- `pdftable` CLI binary at `cmd/pdftable/`. Subcommand surface - mirrors pdfplumber's: `extract [flags]` with - `--pages 1,3-5`, `--tables`, `--text`, `--format json|text`, - `--vertical-strategy`, `--horizontal-strategy`, the full set of - tolerance flags, `--min-words-vertical / horizontal`, - `--explicit-vertical-lines / horizontal-lines`, and `--indent`. - Stdlib `flag` package only — no third-party CLI dependencies. - Positional argument can appear before OR after flags - (pdfplumber-style invocation). Tested via - `cmd/pdftable/main_test.go` against the existing golden fixtures. -- New `layout.SourceText` enum value tagging edges produced by the - text strategy. `layout.SourceExplicit` was already in place from - v0.2.0; the explicit-strategy implementation now writes through - to it as the primary source. -- Hand-crafted borderless fixture `testdata.TableBorderless()` - (3-column × 4-row narrative table conveyed by whitespace alignment - only, no rules drawn). Used by the new text-strategy unit tests - and pdfplumber parity test. The generated PDF is in - `testdata/golden/table-3x4-borderless.pdf`. -- Golden-file parity test `TestGoldenTablesTextStrategyAgainstPdfplumber` - driven by `*.tables-text.expected.json` files. The - `table-3x4-borderless` fixture matches pdfplumber's - `find_tables({text, text})` cell-for-cell. Regenerate via the new - `scripts/capture_pdfplumber_text_golden.py` helper. -- `scripts/capture_pdfplumber_text_golden.py`: tiny Python helper - that captures pdfplumber's text-strategy output for every fixture - with a sibling `.tables-text.target` marker. Mirrors the existing - `scripts/gen_golden.py` workflow for the line-strategy goldens. - -### Changed - -- `Page.FindTables` / `Page.ExtractTables` no longer return - `ErrUnsupported` for `text` or `explicit` strategies — all four - strategies are now implemented. The error is still returned for - unknown strategy strings (typo guard). -- `TableSettings` field docs updated to reflect the implemented - semantics of `MinWordsVertical` / `MinWordsHorizontal` and the - Explicit*Lines slices. -- README's "Tables" section restructured: side-by-side - pdfplumber→pdftable examples for all four strategies, plus a - mixed-strategy snippet and a new "CLI" section. - -### Known limitations - -- Cell text fidelity on the text strategy depends on the same font - metrics as v0.2.x: PDFs that use standard-14 fonts without - bundled AFM tables can report intra-word gaps as zero, producing - cells like "Nohorizontal" where pdfplumber gets "No horizontal". - Structural parity (table count, row count, column count) matches - exactly; cell text matches verbatim on PDFs whose fonts have - bundled metrics or `/Widths` arrays. AFM-table bundling is a - v0.4.x goal. -- Mixed-strategy snap/join uses a single global tolerance. If a - page mixes drawn rules at one X coordinate and word-cluster - edges at a slightly different X, the two won't merge unless - `SnapTolerance` is widened. This matches pdfplumber's behaviour - but is worth noting for callers tuning a mixed pipeline. - -## [0.2.0] - 2026-05-27 - -Phase 1.3.C — table-finding via ruled lines. Direct port of -pdfplumber's `TableFinder` + cells-from-edges algorithm (`table.py`). -The v0.1.x public API surface is unchanged; v0.2.0 only adds methods -to the `Page` interface and new top-level types, so existing callers -compile and run as-is. - -### Added - -- `Page.FindTables(settings TableSettings) ([]TableFinder, error)` — - geometry-only stage of the pipeline. Returns one TableFinder per - detected table group with the merged edges, intersections, raw - cells, and assembled per-table CellsGrid exposed for debugging / - custom rendering. -- `Page.ExtractTables(settings TableSettings) ([]*Table, error)` — - wraps FindTables, runs per-cell text extraction, returns fully - populated `Table` structs. Cell text is the dense extract\_text - output for chars whose centre point falls inside the cell bbox, - with leading / trailing whitespace stripped. Empty cells produce - `""`. -- `TableSettings` struct with `DefaultTableSettings()` constructor - carrying pdfplumber-matching defaults (snap\_tolerance=3, - join\_tolerance=3, edge\_min\_length=3, edge\_min\_length\_prefilter=1, - intersection\_tolerance=3, text\_tolerance=3). -- `TableStrategy` enum with constants `StrategyLines`, - `StrategyLinesStrict`, `StrategyText`, `StrategyExplicit`. Only - `StrategyLines` and `StrategyLinesStrict` are implemented in this - release; `StrategyText` and `StrategyExplicit` are deferred to - v0.3.0 and return `ErrUnsupported` (with a clear "Phase 1.3.D" - message) so callers don't get silent empty results. -- `Table` (rows × columns of cell text + bbox + per-cell bbox grid), - `TableFinder` (edges + intersections + cells + tables), `TableBox` - (one assembled table's geometry: bbox + Rows × Cols grid), - `Intersection` (one edge-crossing point with its participating - vertical and horizontal edges). -- Internal `internal/layout` package: `Edge` type with `FromLine`, - `FromRect`, `FromCurve` constructors, plus the snap → join → - filter pipeline (`SnapEdges`, `JoinEdges`, `MergeEdges`, - `FilterEdgesByLength`, `FilterEdgesBySource`, - `FilterEdgesByOrientation`, `SortEdges`). -- Golden-file parity test against pdfplumber's `find_tables({"lines"})` - on the `issue-466-example.pdf` fixture (4×3 + 2×3 ruled tables). - Test infrastructure (`TestGoldenTablesAgainstPdfplumber` in - `golden_test.go`) loads any `*.tables.expected.json` fixture in - `testdata/golden/` and compares cell-for-cell after whitespace - normalisation. Regenerate via `python scripts/gen_golden.py`. -- New hand-crafted fixture: `testdata.TableRuled()` — minimal - 2-column × 3-row ruled table with predictable text ("Name", "Age"; - "Alice", "30"; "Bob", "25") for unit testing the public API - surface without depending on third-party PDFs. Generator script - at `scripts/gen_table_fixture.go`. -- Algorithm-level unit tests in `table_test.go`: hand-crafted edge - lists exercising `edgesToIntersections`, `intersectionsToCells`, - `cellsToTables`, `assembleTableBox`, and the full `runTableFinder` - pipeline. -- README "Tables" section with a side-by-side Go / pdfplumber - example. The example is also extracted as a runnable program at - `examples/extract_tables/main.go` so changes to the API surface - break the example at build time. - -### Deferred (planned for v0.3.0 — Phase 1.3.D) - -- `StrategyText`: infer table edges from word alignment (clusters of - words sharing x0 / x1 / centre, clusters of words sharing top / - bottom). Useful for PDFs whose tables have no ruled lines (e.g. - banking statements, scanned-then-OCR'd documents). -- `StrategyExplicit`: caller-supplied edges via - `TableSettings.ExplicitVerticalLines` / - `ExplicitHorizontalLines`. In v0.2.0 these settings are accepted - and added on top of the derived edges (helpful when a column - boundary isn't drawn), but they don't form the only source of - edges yet. - -### Known limitations - -- The cell-text extraction shares the v0.1.x word-grouping engine, - which depends on font metrics. Cells whose glyphs use standard-14 - fonts WITHOUT the bundled AFM tables can have intra-word gaps - reported as "no gap" — e.g. "Hello World" comes out as - "HelloWorld". This was already documented for v0.1.0; for v0.2.0 - it means the parity test against - `la-precinct-bulletin-2014-p1.pdf` (which uses Helvetica-Bold) - fails on cell text equality. The fixture is not checked in to - avoid CI noise; it'll be re-added once the AFM bundle lands in - v0.2.x. -- `senate-expenditures.pdf` produces 7 cells where pdfplumber finds - 10. The divergence is in how snap+join unifies edges that share a - near-collinear endpoint but differ slightly in the perpendicular - axis; under investigation as a follow-up issue. The fixture is - not in the golden set yet. - -## [0.1.1] - 2026-05-27 - -### Fixed +## [0.5.0] - 2026-08-20 -- StandardEncoding, WinAnsiEncoding, MacRomanEncoding, and - PDFDocEncoding are now driven from a single source of truth - (`encodingRows`) that mirrors pdfminer.six's `latin_enc.py` and PDF - Reference 1.7 Appendix D.2. The previous tables silently dropped - ~32 named glyphs per encoding outside printable ASCII — most - visibly the smart quotes (`’ ‘ “ ”`), en/em dashes (`– —`), - bullet (`•`), florin (`ƒ`), and dagger marks (`† ‡`). PDFs that - used these without a `/ToUnicode` map (the common case for PDF/A - filings, SEC 10-Ks, and most LaTeX-emitted documents) returned - empty or garbled text where these glyphs appeared. -- `AdobeGlyphToUnicode` now resolves the full Adobe Glyph List for - common Latin/typographic glyphs (~250 entries) instead of a minimal - ~30-entry table. Added support for AGL §2 compound names (`f_i` - decomposes to `fi`) and variant suffixes (`.alt`, `.sc` are - stripped before lookup). -- StandardEncoding now correctly maps slot 0x27 to `quoteright` - (`’`, U+2019) and 0x60 to `quoteleft` (`‘`, U+2018), matching the - PDF spec. WinAnsi/MacRoman/PDFDoc keep ASCII `'` and `` ` `` at - those slots, as the spec requires. - -### Note - -This is a behavior change for callers that depended on the pre-v0.1.1 -ASCII-identity behavior of StandardEncoding at 0x27 / 0x60. The new -behavior is spec-correct and matches what pdfplumber, pdfminer.six, -and Ghostscript emit for the same input. - -## [0.1.0] - 2026-05-26 - -Phase 1.3.B — words and text extraction. Direct port of pdfplumber's -`WordExtractor`, `extract_text`, `extract_text_simple`. The v0.0.1 -public API surface is unchanged; v0.1.0 only adds methods to the -`Page` interface, so existing callers compile and run as-is. - - - column of a financial statement — is left alone. Cell bboxes are - merged too, which matters when they drive a citation highlight. -- `Page.Words(opts WordOpts) ([]Word, error)` — extract positioned - text runs. Each `Word` carries `Text`, `X0/Y0/X1/Y1` bbox, - `Upright`, `Direction` (ltr/rtl/ttb/btt), `FontName`, `FontSize`, - and an optional `Chars` slice (when `WordOpts.KeepChars=true`). -- `Page.ExtractText(opts TextOpts) (string, error)` — page text as a - single string. Supports both dense (`Layout=false`, the default) - and layout-preserving (`Layout=true`) modes. The layout mode emits - a fixed-width grid mimicking `pdftotext -layout` / pdfplumber's - `extract_text(layout=True)`. -- `Page.ExtractTextSimple(xTolerance, yTolerance float64) (string, error)` — - no-frills extraction baseline (ports pdfplumber's - `extract_text_simple`). -- `WordOpts` / `TextOpts` option structs with `DefaultWordOpts()` / - `DefaultTextOpts()` constructors carrying pdfplumber-matching - defaults (XTolerance=3, YTolerance=3, Expand=true). -- `BBox` value type with `Union`, `Intersect`, `Contains`, `Snap`, - `MergeBBoxes`, `BBoxOfChar`, `BBoxOfChars` helpers. -- Internal clustering primitives in `clustering.go`: - `clusterFloat1D`, `makeClusterDict`, `clusterObjects[T]`, - `groupObjectsByAttr[T,K]`, `dedupeChars`. Ports of - pdfplumber/utils/clustering.py. -- Ligature expansion table (fi, fl, ff, ffi, ffl, ſt, st → fi/fl/ff/ffi/ffl/st). -- Golden-file parity tests against pdfplumber output on three - fixtures (hello.pdf, rules.pdf, simple1.pdf). Regenerate via - `python scripts/gen_golden.py`. - -### Known limitations - -- Word bboxes drift by up to ~10 PDF points from pdfplumber's output - on standard-14 fonts because the AFM metrics aren't yet bundled. - Word text + count + order match exactly. The AFM bundle is a v0.2.x - goal. -- `extract_text_lines` (regex-based line extraction) is not yet - ported. -- `TextMap.search` is not yet ported. - -## [0.0.1] - 2026-05-26 - -Initial release. Phase 1.3.A — content-stream primitives layer. - -### Added - -- `TableSettings.MergeSplitTokens` (default **off**) merges two adjacent - cells when the column boundary between them falls inside a single - token. The `text` strategy derives columns by clustering word edges, so - a narrow band that happens to align down the page becomes a column even - when it cuts a value in half — a real 10-K balance sheet yields - `| ( | 16,135) |` for `(16,135)` and `| December 3 | 1, |` for - `December 31,`. Off by default because pdfplumber produces the same - splits (verified against pdfplumber 0.11.9), so enabling it silently - would break parity. Merging is bounded by `TextTolerance`, the same - threshold word grouping uses, so a genuine column gutter — the `# Changelog - -All notable changes to this project will be documented in this file. +Text fidelity, end to end. Real font metrics for the 14 standard PDF +fonts, correct glyph geometry, cell content that no longer loses +characters at a table's edge, and the first measured evaluation of table +behaviour against a public dataset. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -Real font metrics for the 14 standard PDF fonts, plus a font-coverage -test corpus. One behaviour change to a default, noted below. +One default changes behaviour for every existing caller — see +**Changed (behaviour)** — so this is a minor bump, not a patch. ### Changed (behaviour) @@ -1433,12 +25,13 @@ test corpus. One behaviour change to a default, noted below. `WordExtractor` ends a word *at* a whitespace glyph, before any gap test runs; pdftable dropped spaces and re-inferred boundaries purely from the gap. At small type that silently over-merges: an 8pt space is - 278/1000 x 8 = 2.22pt, under the 3pt `XTolerance`, so + 278/1000 × 8 = 2.22pt, under the 3pt `XTolerance`, so `Wim illegible 3,142 (16,048)` came back as one run while pdfplumber - returned four words. Body type in real documents is routinely 8-9pt, so + returned four words. Body type in real documents is routinely 8–9pt, so this was not an edge case. With it on, pdftable matches pdfplumber word-for-word and coordinate-for-coordinate across all 12 Latin - standard fonts at 8/12/24pt. + standard fonts at 8/12/24pt. The low-level `WordExtractor` is + unchanged and stays pdfplumber-exact. ### Fixed @@ -1490,6 +83,49 @@ test corpus. One behaviour change to a default, noted below. was dropped, turning accounting notation for −16,048 into +16,048; across five financial statements it flipped the sign of 19% of all negative numbers while leaving every magnitude correct. +- fix: `MergeSplitTokens` is decided once per table, not per row. A + column boundary is a property of the table, so a per-row decision + sheared the grid: on 3M's 2018 10-K page 58 the header band contains + the split (`December 3` + `1,`) and the data rows below it do not, so + the header merged to 3 cells while the data rows stayed at 5 and the + second date sat above the first column of figures. The decision is now + made once per boundary across every row and applied uniformly, keeping + the table rectangular. The trade is that one row containing a split + collapses that boundary for the whole table — the right direction, + since rectangularity matters more to a positional consumer than + per-cell purity. + +### Added + +- `TableSettings.MergeSplitTokens` (default **off**) merges two adjacent + cells when the column boundary between them falls inside a single + token. The `text` strategy derives columns by clustering word edges, so + a narrow band that happens to align down the page becomes a column even + when it cuts a value in half — a real 10-K balance sheet yields + `| ( | 16,135) |` for `(16,135)` and `| December 3 | 1, |` for + `December 31,`. Off by default because pdfplumber produces the same + splits (verified against pdfplumber 0.11.9), so enabling it silently + would break parity. Merging is bounded by `TextTolerance`, the same + threshold word grouping uses, so a genuine column gutter is left alone. + Cell bboxes are merged too, which matters when they drive a citation + highlight. +- `StrategyAuto` (**opt-in**, not a default) picks a strategy 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 + load-bearing — 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 `text` will happily report a table for it. Rulings on the other + axis are the evidence that a table is genuinely present; without that + evidence `Auto` declines to guess. It stays opt-in because the + hypothesis it was built to test came back negative — see + [the negative result](docs/evaluations/2026-08-02-strategy-auto-negative-result.md). +- `BBox.Viewport(pageHeight, scale)` and `BBox.Normalized(pageWidth, + pageHeight)` return a `ViewRect` in viewer coordinates (origin + top-left, Y down) for drawing citation highlights over a rendered + page. Every coordinate the package reports is already normalised — + MediaBox origin translated to (0,0) and `/Rotate` applied — so the + only conversion needed is the Y flip, now done once and tested. ### Changed @@ -1497,15 +133,66 @@ test corpus. One behaviour change to a default, noted below. down from a 15pt envelope. Measured drift against the fixtures is exactly 0.0000pt. The old envelope was wide enough to pass with the font-metric bugs fully present, so it could not have caught them. +- A font-coverage test corpus replaces the previous golden corpus of 26 + words in a single font, which could not validate extraction fidelity. + +### Evaluation + +First measurement of pdftable against a public dataset. Harness in +[`bench/icdar2013`](bench/icdar2013/), write-ups in +[`docs/evaluations/`](docs/evaluations/). + +ICDAR 2013 Table Competition (Smock's corrected edition — 125 +born-digital PDFs, 39,524 ground-truth adjacency relations), against +pdfplumber 0.11.9: + +| system | precision | recall | F1 | +| --- | --- | --- | --- | +| pdftable (`lines`) | 0.865 | 0.229 | **0.362** | +| pdfplumber (`lines`) | 0.868 | 0.235 | **0.370** | +| **pdftable + oracle boundaries** | **0.948** | **0.922** | **0.935** | + +Two conclusions: + +- **Parity with pdfplumber holds at the table level**, not only for text. +- **0.362 → 0.935 given a correct grid.** Essentially the entire + end-to-end gap is table *structure*, not text extraction — cell + filling, coordinates and text fidelity are not the limiting factor. A + layout model supplying row/column geometry converts almost the whole + gap, and pdftable keeps what a generative model cannot give: exact cell + text and exact coordinates. Integration uses `StrategyExplicit` with + `MergeSplitTokens = false`; leaving it on drops 0.935 to 0.726, because + that setting exists to repair a geometric guess and there is nothing to + repair in a correct grid. + +### CI + +- The test matrix runs on every pull request, deliberately unfiltered by + base branch: a `branches: [main]` filter means stacked PRs (base = a + feature branch) silently get no CI at all. + +## [0.4.0] - 2026-07-03 + +Word segmentation that adapts to font size, and honours the PDF's own +whitespace. ### Added -- `BBox.Viewport(pageHeight, scale)` and `BBox.Normalized(pageWidth, - pageHeight)` return a `ViewRect` in viewer coordinates (origin - top-left, Y down) for drawing citation highlights over a rendered - page. Every coordinate the package reports is already normalised — - MediaBox origin translated to (0,0) and `/Rotate` applied — so the - only conversion needed is the Y flip, now done once and tested. +- `TextOpts.XToleranceRatio` (default 0.15) makes the word-gap tolerance + size-relative — ratio × glyph size — instead of a fixed 3pt applied + uniformly across every font size in a document. Matches pdfplumber's + `x_tolerance_ratio` semantics. A fixed 3pt was too coarse for small + body text, so tightly-set design PDFs merged words outright + (`Vectorsarefuzzy`, `whatevershapeyourstackprefers`). +- `TextOpts.UseExplicitSpaces` honours the PDF's own whitespace glyphs as + authoritative word boundaries instead of dropping them and re-inferring + from gaps. Strictly more robust for the many PDFs that emit real + spaces. Enabled by `DefaultTextOpts` here; `DefaultWordOpts()` follows + in 0.5.0. +- The Vectorless whitepaper and technical docs as regression fixtures. + +Both generalizations are enabled through `DefaultTextOpts`. The +low-level `WordExtractor` is untouched and stays pdfplumber-exact. ## [0.3.1] - 2026-05-29 @@ -1815,9 +502,8 @@ public API surface is unchanged; v0.1.0 only adds methods to the Initial release. Phase 1.3.A — content-stream primitives layer. +### Added - column of a financial statement — is left alone. Cell bboxes are - merged too, which matters when they drive a citation highlight. - Public API: `Open`, `OpenBytes`, `OpenFile` → `Document`. - `Document.NumPages`, `Document.Page(n)`, `Document.Pages()` iterator, `Document.Close`. @@ -1858,6 +544,9 @@ Initial release. Phase 1.3.A — content-stream primitives layer. - Type 3 fonts (their glyph procedures are themselves content streams). - Vertical writing mode. +[Unreleased]: https://github.com/hallelx2/pdftable/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.5.0 +[0.4.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.4.0 [0.3.1]: https://github.com/hallelx2/pdftable/releases/tag/v0.3.1 [0.3.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.3.0 [0.2.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.2.0 From c26d9258a6e68e6ff5016ea8a4943cb4f4a92396 Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Thu, 20 Aug 2026 17:13:29 +0100 Subject: [PATCH 2/4] style: gofmt eight files No behaviour change -- alignment and spacing only. The drift accumulated because nothing checked for it; the CI gate added alongside this stops it recurring. --- cmd/pdftable/main.go | 48 ++++++------ internal/pdf/cmap.go | 16 ++-- internal/pdf/cmap_test.go | 6 +- internal/pdf/content.go | 29 +++---- internal/pdf/font.go | 12 +-- internal/pdf/font_test.go | 4 +- internal/pdf/ops.go | 158 +++++++++++++++++++------------------- text_test.go | 4 +- 8 files changed, 140 insertions(+), 137 deletions(-) diff --git a/cmd/pdftable/main.go b/cmd/pdftable/main.go index 5fbf3ce..5508d5d 100644 --- a/cmd/pdftable/main.go +++ b/cmd/pdftable/main.go @@ -96,23 +96,23 @@ Documentation: https://github.com/hallelx2/pdftable`) // extractFlags is the parsed flag set for the extract subcommand. type extractFlags struct { - pages string - tables bool - text bool - format string - verticalStrategy string - horizontalStrategy string - snapTolerance float64 - joinTolerance float64 - edgeMinLength float64 - edgeMinLengthPrefilter float64 - intersectionTolerance float64 - textTolerance float64 - minWordsVertical int - minWordsHorizontal int - explicitVerticalLines string - explicitHorizontalLines string - indent int + pages string + tables bool + text bool + format string + verticalStrategy string + horizontalStrategy string + snapTolerance float64 + joinTolerance float64 + edgeMinLength float64 + edgeMinLengthPrefilter float64 + intersectionTolerance float64 + textTolerance float64 + minWordsVertical int + minWordsHorizontal int + explicitVerticalLines string + explicitHorizontalLines string + indent int } // runExtract parses extract-subcommand args, opens the PDF, and @@ -369,16 +369,16 @@ type tablesOutput struct { } type pageTablesOutput struct { - Number int `json:"number"` - Width float64 `json:"width"` - Height float64 `json:"height"` - Tables []tableOutput `json:"tables"` + Number int `json:"number"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Tables []tableOutput `json:"tables"` } type tableOutput struct { - BBox [4]float64 `json:"bbox"` - Rows [][]string `json:"rows"` - Cells [][][4]float64 `json:"cells"` + BBox [4]float64 `json:"bbox"` + Rows [][]string `json:"rows"` + Cells [][][4]float64 `json:"cells"` } // emitTables runs ExtractTables on each requested page and writes the diff --git a/internal/pdf/cmap.go b/internal/pdf/cmap.go index 60ee7a4..bd58f79 100644 --- a/internal/pdf/cmap.go +++ b/internal/pdf/cmap.go @@ -225,14 +225,14 @@ type cmapTokKind int const ( tokInvalid cmapTokKind = iota tokNumber - tokHex // - tokLiteralString // (foo) - tokName // /Name - tokKeyword // bfchar, endbfchar, def, … - tokArrayBegin // [ - tokArrayEnd // ] - tokDictBegin // << - tokDictEnd // >> + tokHex // + tokLiteralString // (foo) + tokName // /Name + tokKeyword // bfchar, endbfchar, def, … + tokArrayBegin // [ + tokArrayEnd // ] + tokDictBegin // << + tokDictEnd // >> ) type cmapTok struct { diff --git a/internal/pdf/cmap_test.go b/internal/pdf/cmap_test.go index 60505f9..d6a6868 100644 --- a/internal/pdf/cmap_test.go +++ b/internal/pdf/cmap_test.go @@ -117,9 +117,9 @@ func TestHexBytesToUTF16BE(t *testing.T) { in []byte want string }{ - {[]byte{0x00, 0x41}, "A"}, // BMP - {[]byte{0x00, 0x66, 0x00, 0x69}, "fi"}, // two BMP code units - {[]byte{0xD8, 0x3D, 0xDE, 0x00}, "\U0001F600"}, // surrogate pair → 😀 + {[]byte{0x00, 0x41}, "A"}, // BMP + {[]byte{0x00, 0x66, 0x00, 0x69}, "fi"}, // two BMP code units + {[]byte{0xD8, 0x3D, 0xDE, 0x00}, "\U0001F600"}, // surrogate pair → 😀 } for _, tc := range cases { got := hexBytesToUTF16BE(tc.in) diff --git a/internal/pdf/content.go b/internal/pdf/content.go index 6d015f6..f63118e 100644 --- a/internal/pdf/content.go +++ b/internal/pdf/content.go @@ -81,12 +81,12 @@ type Sink interface { // CharEvent is the per-glyph data emitted by EmitChar. type CharEvent struct { - Text string - X0, Y0, X1, Y1 float64 - FontName string - FontSize float64 - Upright bool - Advance float64 + Text string + X0, Y0, X1, Y1 float64 + FontName string + FontSize float64 + Upright bool + Advance float64 } // PathEvent is the data emitted by EmitPath when a path-painting op @@ -145,11 +145,11 @@ type XObject struct { // every operator just pops N typed values — and Operand keeps the // handler signatures tidy. type Operand struct { - Kind OperandKind - Number float64 - Name string // for name and keyword operands - String []byte // for literal/hex strings - Array []Operand // for [ ... ] arrays (TJ uses this) + Kind OperandKind + Number float64 + Name string // for name and keyword operands + String []byte // for literal/hex strings + Array []Operand // for [ ... ] arrays (TJ uses this) } type OperandKind int @@ -332,8 +332,11 @@ func opRectangle(it *Interpreter, args []Operand) error { // --- Path painting ---- -func opStroke(it *Interpreter, _ []Operand) error { return it.paintPath(true, false, false) } -func opCloseStroke(it *Interpreter, _ []Operand) error { _ = opClosePath(it, nil); return it.paintPath(true, false, false) } +func opStroke(it *Interpreter, _ []Operand) error { return it.paintPath(true, false, false) } +func opCloseStroke(it *Interpreter, _ []Operand) error { + _ = opClosePath(it, nil) + return it.paintPath(true, false, false) +} func opFill(it *Interpreter, _ []Operand) error { return it.paintPath(false, true, false) } func opFillEvenOdd(it *Interpreter, _ []Operand) error { return it.paintPath(false, true, true) } func opFillStroke(it *Interpreter, _ []Operand) error { return it.paintPath(true, true, false) } diff --git a/internal/pdf/font.go b/internal/pdf/font.go index 59c4c9f..358332c 100644 --- a/internal/pdf/font.go +++ b/internal/pdf/font.go @@ -588,11 +588,11 @@ var adobeGlyphTable = map[string]string{ "divide": "÷", // --- Common ligatures (used in /Differences arrays) ------------- - "fi": "fi", - "fl": "fl", - "ff": "ff", - "ffi": "ffi", - "ffl": "ffl", + "fi": "fi", + "fl": "fl", + "ff": "ff", + "ffi": "ffi", + "ffl": "ffl", "longs": "ſ", } @@ -616,7 +616,7 @@ var ( // WinAnsiEncoding / PDFDocEncoding (each -1 if the glyph is unmapped // in that encoding). type encodingRow struct { - name string + name string std, mac, win, pdf int } diff --git a/internal/pdf/font_test.go b/internal/pdf/font_test.go index 04cbf0b..5aa8a8c 100644 --- a/internal/pdf/font_test.go +++ b/internal/pdf/font_test.go @@ -140,8 +140,8 @@ func TestAdobeGlyphRecognisers(t *testing.T) { func TestFontDecodeUnicodeFallback(t *testing.T) { enc := EncodingByName("WinAnsiEncoding") f := &Font{ - BaseFont: "TestFont", - IsSimple: true, + BaseFont: "TestFont", + IsSimple: true, cid2unicodeEncoding: enc, } // Encoding hit. diff --git a/internal/pdf/ops.go b/internal/pdf/ops.go index 4c57481..f77f2f3 100644 --- a/internal/pdf/ops.go +++ b/internal/pdf/ops.go @@ -48,85 +48,85 @@ func getOperatorTable() map[string]opHandler { func buildOperatorTable() map[string]opHandler { return map[string]opHandler{ - // --- Graphics state ---- - "q": opSaveState, - "Q": opRestoreState, - "cm": opConcatMatrix, - "w": opLineWidth, - "J": opNoop, // line cap - "j": opNoop, // line join - "M": opNoop, // miter limit - "d": opNoop, // line dash - "ri": opNoop, // rendering intent - "i": opNoop, // flatness - "gs": opNoop, // gstate dict (name) - - // --- Path construction ---- - "m": opMove, - "l": opLine, - "c": opCurve, - "v": opCurveV, - "y": opCurveY, - "h": opClosePath, - "re": opRectangle, - - // --- Path painting ---- - "S": opStroke, - "s": opCloseStroke, - "f": opFill, - "F": opFill, // obsolete alias - "f*": opFillEvenOdd, - "B": opFillStroke, - "B*": opFillStrokeEvenOdd, - "b": opCloseFillStroke, - "b*": opCloseFillStrokeEvenOdd, - "n": opEndPath, - - // --- Clipping ---- - "W": opNoop, - "W*": opNoop, - - // --- Text state ---- - "Tc": opTc, - "Tw": opTw, - "Tz": opTz, - "TL": opTL, - "Tf": opTf, - "Tr": opTr, - "Ts": opTrise, - - // --- Text objects & positioning ---- - "BT": opBeginText, - "ET": opEndText, - "Td": opTd, - "TD": opTD, - "Tm": opTm, - "T*": opTStar, - - // --- Text showing ---- - "Tj": opTj, - "TJ": opTJ, - "'": opQuote, // move to next line and show. - "\"": opDoubleQuote, // set spacing, move, show. - - // --- Colour (we don't track but pop the operands) ---- - "CS": opNoop, "cs": opNoop, - "SC": opNoop, "sc": opNoop, - "SCN": opNoop, "scn": opNoop, - "G": opNoop, "g": opNoop, - "RG": opNoop, "rg": opNoop, - "K": opNoop, "k": opNoop, - "sh": opNoop, - - // --- XObject invocation ---- - "Do": opDo, - - // --- Marked content (passthrough) ---- - "BMC": opNoop, "BDC": opNoop, "EMC": opNoop, - "MP": opNoop, "DP": opNoop, - - // --- Compatibility ---- - "BX": opNoop, "EX": opNoop, + // --- Graphics state ---- + "q": opSaveState, + "Q": opRestoreState, + "cm": opConcatMatrix, + "w": opLineWidth, + "J": opNoop, // line cap + "j": opNoop, // line join + "M": opNoop, // miter limit + "d": opNoop, // line dash + "ri": opNoop, // rendering intent + "i": opNoop, // flatness + "gs": opNoop, // gstate dict (name) + + // --- Path construction ---- + "m": opMove, + "l": opLine, + "c": opCurve, + "v": opCurveV, + "y": opCurveY, + "h": opClosePath, + "re": opRectangle, + + // --- Path painting ---- + "S": opStroke, + "s": opCloseStroke, + "f": opFill, + "F": opFill, // obsolete alias + "f*": opFillEvenOdd, + "B": opFillStroke, + "B*": opFillStrokeEvenOdd, + "b": opCloseFillStroke, + "b*": opCloseFillStrokeEvenOdd, + "n": opEndPath, + + // --- Clipping ---- + "W": opNoop, + "W*": opNoop, + + // --- Text state ---- + "Tc": opTc, + "Tw": opTw, + "Tz": opTz, + "TL": opTL, + "Tf": opTf, + "Tr": opTr, + "Ts": opTrise, + + // --- Text objects & positioning ---- + "BT": opBeginText, + "ET": opEndText, + "Td": opTd, + "TD": opTD, + "Tm": opTm, + "T*": opTStar, + + // --- Text showing ---- + "Tj": opTj, + "TJ": opTJ, + "'": opQuote, // move to next line and show. + "\"": opDoubleQuote, // set spacing, move, show. + + // --- Colour (we don't track but pop the operands) ---- + "CS": opNoop, "cs": opNoop, + "SC": opNoop, "sc": opNoop, + "SCN": opNoop, "scn": opNoop, + "G": opNoop, "g": opNoop, + "RG": opNoop, "rg": opNoop, + "K": opNoop, "k": opNoop, + "sh": opNoop, + + // --- XObject invocation ---- + "Do": opDo, + + // --- Marked content (passthrough) ---- + "BMC": opNoop, "BDC": opNoop, "EMC": opNoop, + "MP": opNoop, "DP": opNoop, + + // --- Compatibility ---- + "BX": opNoop, "EX": opNoop, } } diff --git a/text_test.go b/text_test.go index 6afdad2..375e54c 100644 --- a/text_test.go +++ b/text_test.go @@ -399,8 +399,8 @@ func TestExpandLigatures(t *testing.T) { "ffl": "ffl", "ſt": "st", "st": "st", - "A": "A", // pass-through - "": "", // pass-through + "A": "A", // pass-through + "": "", // pass-through } for in, want := range cases { if got := expandLigatures(in); got != want { From 75f477c58ca2b95ad324903b16b929513f8f867e Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Thu, 20 Aug 2026 17:13:41 +0100 Subject: [PATCH 3/4] ci: release workflow on tag push, and a gofmt gate on every PR Every tag from v0.0.1 to v0.4.0 was pushed by hand. There was no GitHub Release behind any of them, nothing verified the tree before the tag became permanent, and pkg.go.dev only picked pdftable up whenever its next scan happened to run. A Go module tag cannot be moved once the proxy has served it, so "verify after tagging" is not a recoverable order of operations. Ported from llmgate's release.yml, which already solved this: semver validation, go.mod tidiness, vet + build + race tests, GitHub Release with generated notes, then a proxy warm so pkg.go.dev indexes the version immediately. One step is new here. The release fails if CHANGELOG.md has no section matching the tag, because shipping v0.4.0 with no entry is exactly the failure this repo already had, and a grep is enough to make it impossible. The gofmt gate runs on ubuntu only -- gofmt is platform-independent, so running it across the three-OS matrix would report identical drift three times. --- .github/workflows/release.yml | 83 +++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 11 +++++ 2 files changed, 94 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7f33705 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Tag → GitHub Release → proxy.golang.org + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: Validate tag is semver + run: | + tag="${GITHUB_REF_NAME}" + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::tag '$tag' is not a valid Go-module semver (vX.Y.Z[-pre][+meta])" + exit 1 + fi + echo "tag $tag accepted" + + - name: Verify the tag has a CHANGELOG entry + # A release with no changelog section is how v0.4.0 shipped + # undocumented. Fail the release rather than discover it later. + run: | + version="${GITHUB_REF_NAME#v}" + if ! grep -q "^## \[${version}\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## [${version}]' section" + exit 1 + fi + echo "changelog entry for ${version} found" + + - name: Verify tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: Vet + build + test + run: | + test -z "$(gofmt -l .)" || { echo "::error::gofmt drift"; gofmt -l .; exit 1; } + go vet ./... + go build ./... + go test -race -count=1 -timeout=10m ./... + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + name: ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} + body: | + Install: + + ```bash + go get github.com/${{ github.repository }}@${{ github.ref_name }} + ``` + + See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md) + for the curated notes. Commit-level changelog auto-generated below. + + - name: Warm proxy.golang.org + # Pull the module through the proxy so pkg.go.dev indexes this version + # immediately instead of waiting for the next scan. + run: | + module=$(go list -m) + tag="${GITHUB_REF_NAME}" + echo "warming proxy for $module@$tag" + curl -fsSL "https://proxy.golang.org/${module}/@v/${tag}.info" || true + curl -fsSL "https://proxy.golang.org/${module}/@v/${tag}.mod" || true + curl -fsSL "https://sum.golang.org/lookup/${module}@${tag}" || true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9924ece..8eb4a3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,17 @@ jobs: with: go-version: ${{ matrix.go }} + - name: Gofmt + # Ubuntu only: gofmt is platform-independent, and running it on all + # three would report the same drift three times. + if: matrix.os == 'ubuntu-latest' + run: | + drift=$(gofmt -l .) + if [ -n "$drift" ]; then + echo "::error::gofmt drift in:"; echo "$drift" + exit 1 + fi + - name: Go vet run: go vet ./... From fd87062dce769ce3838f9ef8cd849bce0f42256b Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Fri, 21 Aug 2026 13:32:59 +0100 Subject: [PATCH 4/4] refactor!: rename the module to pdfgrab "pdftable" names one of the six things this library does. It also does text extraction at 0.01pt pdfplumber parity, real Adobe AFM metrics for the standard-14 fonts, Symbol and ZapfDingbats encodings, size-relative word clustering, and viewer-space geometry for citation highlighting -- with rasterisation and an addressing layer next. Someone looking for a Go PDF text library with exact coordinates does not find this one, and if they do they assume it is table-only and move on. Now is the only cheap moment. A Go module path IS its import path, so a rename means every consumer rewrites imports and pkg.go.dev starts over. Pre-1.0 with one real consumer that cost is a single PR; after the OSS launch, with docs and SDK quickstarts referencing it, it is a deprecation cycle. v0.5.0 is not tagged yet, so the rename and the release land as one event rather than two migrations. pdfgrab is the umbrella, not a sibling: table finding, the coming render package and the coming addressing layer all sit under it, so there is one name to know rather than a family to explain. The selection API this is heading toward reads naturally at the root -- pdfgrab.Open, doc.At, doc.Resolve, doc.Tables. Module path, package clauses, the CLI directory, the separate bench module, the Python harness and every doc move together. No API surface changes: this is a rename, and the only reason it carries a `!` is the import path. BREAKING CHANGE: the module path is now github.com/hallelx2/pdfgrab and the package is `pdfgrab`. Update imports and qualified identifiers; nothing else changed. github.com/hallelx2/pdftable@v0.4.0 stays published and resolvable, so existing builds keep working until they choose to move. --- .github/workflows/release.yml | 4 +- CHANGELOG.md | 40 +++--- README.md | 130 +++++++++--------- THIRD_PARTY_NOTICES.md | 2 +- bench/README.md | 2 +- bench/go.mod | 8 +- bench/icdar2013/README.md | 4 +- bench/icdar2013/extract.go | 36 ++--- bench/icdar2013/oracle.py | 10 +- bench/icdar2013/run.py | 10 +- bench/icdar2013/score.py | 10 +- cell_edges_test.go | 2 +- char.go | 2 +- clustering.go | 2 +- clustering_test.go | 2 +- cmd/{pdftable => pdfgrab}/main.go | 57 ++++---- cmd/{pdftable => pdfgrab}/main_test.go | 17 ++- ...6-08-02-font-metrics-and-table-fidelity.md | 10 +- .../2026-08-02-icdar2013-table-structure.md | 10 +- ...026-08-02-strategy-auto-negative-result.md | 10 +- ...-08-03-hybrid-ceiling-oracle-boundaries.md | 24 ++-- errors.go | 10 +- examples/extract_tables/main.go | 8 +- finder.go | 8 +- finder_bench_test.go | 4 +- finder_text.go | 14 +- fonts_fixture_test.go | 16 +-- geometry.go | 4 +- geometry_test.go | 2 +- go.mod | 2 +- golden_test.go | 38 ++--- internal/layout/lines.go | 8 +- internal/pdf/afm_widths.go | 2 +- internal/pdf/content.go | 4 +- internal/pdf/reader.go | 4 +- internal/pdf/state.go | 2 +- merge_split_test.go | 2 +- page.go | 10 +- page_test.go | 14 +- pdf.go | 4 +- pdftable.go | 16 +-- pdftable_test.go | 20 +-- scripts/capture_pdfplumber_text_golden.py | 2 +- scripts/gen_font_fixtures.py | 2 +- scripts/gen_golden.py | 6 +- scripts/gen_table_fixture.go | 2 +- spacing_regression_test.go | 2 +- strategy_auto_test.go | 4 +- table.go | 8 +- table_test.go | 14 +- testdata/fixtures.go | 2 +- text.go | 4 +- text_test.go | 6 +- viewport_test.go | 20 +-- 54 files changed, 338 insertions(+), 318 deletions(-) rename cmd/{pdftable => pdfgrab}/main.go (89%) rename cmd/{pdftable => pdfgrab}/main_test.go (91%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f33705..3908cce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,9 @@ jobs: run: | test -z "$(gofmt -l .)" || { echo "::error::gofmt drift"; gofmt -l .; exit 1; } go vet ./... - go build ./... + # Inject the tag into the CLI so `pdfgrab version` reports the + # release rather than a literal someone has to remember to bump. + go build -ldflags "-X main.version=${GITHUB_REF_NAME}" ./... go test -race -count=1 -timeout=10m ./... - name: Create GitHub release diff --git a/CHANGELOG.md b/CHANGELOG.md index 284caa5..7b61dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,12 +23,12 @@ One default changes behaviour for every existing caller — see - **`DefaultWordOpts()` now enables `UseExplicitSpaces`.** pdfplumber's `WordExtractor` ends a word *at* a whitespace glyph, before any gap - test runs; pdftable dropped spaces and re-inferred boundaries purely + test runs; pdfgrab dropped spaces and re-inferred boundaries purely from the gap. At small type that silently over-merges: an 8pt space is 278/1000 × 8 = 2.22pt, under the 3pt `XTolerance`, so `Wim illegible 3,142 (16,048)` came back as one run while pdfplumber returned four words. Body type in real documents is routinely 8–9pt, so - this was not an edge case. With it on, pdftable matches pdfplumber + this was not an edge case. With it on, pdfgrab matches pdfplumber word-for-word and coordinate-for-coordinate across all 12 Latin standard fonts at 8/12/24pt. The low-level `WordExtractor` is unchanged and stays pdfplumber-exact. @@ -38,7 +38,7 @@ One default changes behaviour for every existing caller — see - fix: standard-14 fonts now resolve to their true Adobe AFM advance widths. PDF 1.7 §9.6.2.2 lets those 14 fonts omit `/Widths` entirely, on the assumption that a consumer already knows their metrics — - pdftable did not, so every glyph in such a font fell through to a flat + pdfgrab did not, so every glyph in such a font fell through to a flat 500/1000 guess. `i` and `m` came out the same width, and the error accumulated across a line into up to ~10pt of word-bbox drift. Since the `text` and `lines_strict` table strategies infer column boundaries @@ -138,7 +138,7 @@ One default changes behaviour for every existing caller — see ### Evaluation -First measurement of pdftable against a public dataset. Harness in +First measurement of pdfgrab against a public dataset. Harness in [`bench/icdar2013`](bench/icdar2013/), write-ups in [`docs/evaluations/`](docs/evaluations/). @@ -148,9 +148,9 @@ pdfplumber 0.11.9: | system | precision | recall | F1 | | --- | --- | --- | --- | -| pdftable (`lines`) | 0.865 | 0.229 | **0.362** | +| pdfgrab (`lines`) | 0.865 | 0.229 | **0.362** | | pdfplumber (`lines`) | 0.868 | 0.235 | **0.370** | -| **pdftable + oracle boundaries** | **0.948** | **0.922** | **0.935** | +| **pdfgrab + oracle boundaries** | **0.948** | **0.922** | **0.935** | Two conclusions: @@ -159,7 +159,7 @@ Two conclusions: end-to-end gap is table *structure*, not text extraction — cell filling, coordinates and text fidelity are not the limiting factor. A layout model supplying row/column geometry converts almost the whole - gap, and pdftable keeps what a generative model cannot give: exact cell + gap, and pdfgrab keeps what a generative model cannot give: exact cell text and exact coordinates. Integration uses `StrategyExplicit` with `MergeSplitTokens = false`; leaving it on drops 0.935 to 0.726, because that setting exists to repair a geometric guess and there is nothing to @@ -244,7 +244,7 @@ faster on densely-ruled pages. ## [0.3.0] - 2026-05-27 Phase 1.3.D + 1.3.E — text and explicit table-finding strategies, the -`pdftable` CLI. Completes pdfplumber parity for the four canonical +`pdfgrab` CLI. Completes pdfplumber parity for the four canonical table strategies. The v0.2.x public API surface is unchanged; v0.3.0 only widens what's valid in `TableSettings` and adds the new CLI binary, so existing callers compile and run as-is. @@ -272,7 +272,7 @@ binary, so existing callers compile and run as-is. edges are derived independently then merged together for the intersection pipeline — no orientation-specific logic leaks between them. -- `pdftable` CLI binary at `cmd/pdftable/`. Subcommand surface +- `pdfgrab` CLI binary at `cmd/pdfgrab/`. Subcommand surface mirrors pdfplumber's: `extract [flags]` with `--pages 1,3-5`, `--tables`, `--text`, `--format json|text`, `--vertical-strategy`, `--horizontal-strategy`, the full set of @@ -281,7 +281,7 @@ binary, so existing callers compile and run as-is. Stdlib `flag` package only — no third-party CLI dependencies. Positional argument can appear before OR after flags (pdfplumber-style invocation). Tested via - `cmd/pdftable/main_test.go` against the existing golden fixtures. + `cmd/pdfgrab/main_test.go` against the existing golden fixtures. - New `layout.SourceText` enum value tagging edges produced by the text strategy. `layout.SourceExplicit` was already in place from v0.2.0; the explicit-strategy implementation now writes through @@ -311,7 +311,7 @@ binary, so existing callers compile and run as-is. semantics of `MinWordsVertical` / `MinWordsHorizontal` and the Explicit*Lines slices. - README's "Tables" section restructured: side-by-side - pdfplumber→pdftable examples for all four strategies, plus a + pdfplumber→pdfgrab examples for all four strategies, plus a mixed-strategy snippet and a new "CLI" section. ### Known limitations @@ -544,12 +544,12 @@ Initial release. Phase 1.3.A — content-stream primitives layer. - Type 3 fonts (their glyph procedures are themselves content streams). - Vertical writing mode. -[Unreleased]: https://github.com/hallelx2/pdftable/compare/v0.5.0...HEAD -[0.5.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.5.0 -[0.4.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.4.0 -[0.3.1]: https://github.com/hallelx2/pdftable/releases/tag/v0.3.1 -[0.3.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.3.0 -[0.2.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.2.0 -[0.1.1]: https://github.com/hallelx2/pdftable/releases/tag/v0.1.1 -[0.1.0]: https://github.com/hallelx2/pdftable/releases/tag/v0.1.0 -[0.0.1]: https://github.com/hallelx2/pdftable/releases/tag/v0.0.1 +[Unreleased]: https://github.com/hallelx2/pdfgrab/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/hallelx2/pdfgrab/releases/tag/v0.5.0 +[0.4.0]: https://github.com/hallelx2/pdfgrab/releases/tag/v0.4.0 +[0.3.1]: https://github.com/hallelx2/pdfgrab/releases/tag/v0.3.1 +[0.3.0]: https://github.com/hallelx2/pdfgrab/releases/tag/v0.3.0 +[0.2.0]: https://github.com/hallelx2/pdfgrab/releases/tag/v0.2.0 +[0.1.1]: https://github.com/hallelx2/pdfgrab/releases/tag/v0.1.1 +[0.1.0]: https://github.com/hallelx2/pdfgrab/releases/tag/v0.1.0 +[0.0.1]: https://github.com/hallelx2/pdfgrab/releases/tag/v0.0.1 diff --git a/README.md b/README.md index f1ef9b3..9a772ca 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# pdftable +# pdfgrab A Go-native port of Python's [pdfplumber](https://github.com/jsvine/pdfplumber). -`pdftable` reads PDF documents, walks the content streams, and surfaces +`pdfgrab` reads PDF documents, walks the content streams, and surfaces the positioned primitives — characters, lines, rectangles, curves — that higher-level layout algorithms (text extraction, word grouping, table detection) operate on. It is built on top of @@ -22,17 +22,17 @@ heuristics on. This is that. `v0.3.0` — full pdfplumber parity for table-finding strategies. All four canonical strategies are implemented: `lines`, `lines_strict`, `text`, and `explicit`. Mix and match per-axis (e.g. `vertical="text"` + -`horizontal="lines"`) works as expected. Also ships the `pdftable` +`horizontal="lines"`) works as expected. Also ships the `pdfgrab` CLI for extracting text and tables without writing Go. -[![Go Reference](https://pkg.go.dev/badge/github.com/hallelx2/pdftable.svg)](https://pkg.go.dev/github.com/hallelx2/pdftable) -[![CI](https://github.com/hallelx2/pdftable/actions/workflows/test.yml/badge.svg)](https://github.com/hallelx2/pdftable/actions/workflows/test.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/hallelx2/pdfgrab.svg)](https://pkg.go.dev/github.com/hallelx2/pdfgrab) +[![CI](https://github.com/hallelx2/pdfgrab/actions/workflows/test.yml/badge.svg)](https://github.com/hallelx2/pdfgrab/actions/workflows/test.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ## Install ```sh -go get github.com/hallelx2/pdftable@v0.3.0 +go get github.com/hallelx2/pdfgrab@v0.3.0 ``` Requires Go 1.25+ (uses the standard-library `iter` package for the `Pages()` range-over-func iterator, and pdfcpu v0.12+). @@ -46,11 +46,11 @@ import ( "fmt" "log" - "github.com/hallelx2/pdftable" + "github.com/hallelx2/pdfgrab" ) func main() { - doc, err := pdftable.OpenFile("report.pdf") + doc, err := pdfgrab.OpenFile("report.pdf") if err != nil { log.Fatal(err) } @@ -65,8 +65,8 @@ func main() { n, len(chars), len(rects), len(lines)) // Words and text extraction (v0.1.0). - words, _ := page.Words(pdftable.DefaultWordOpts()) - text, _ := page.ExtractText(pdftable.DefaultTextOpts()) + words, _ := page.Words(pdfgrab.DefaultWordOpts()) + text, _ := page.ExtractText(pdfgrab.DefaultTextOpts()) fmt.Printf(" %d words; first line: %q\n", len(words), firstLine(text)) } @@ -178,22 +178,22 @@ type TextOpts struct { // Sentinel errors. var ( - ErrInvalidPDF = errors.New("pdftable: invalid PDF") - ErrPageOutOfRange = errors.New("pdftable: page out of range") - ErrUnsupported = errors.New("pdftable: unsupported feature") - ErrEncrypted = errors.New("pdftable: encrypted PDF (decryption not yet supported)") + ErrInvalidPDF = errors.New("pdfgrab: invalid PDF") + ErrPageOutOfRange = errors.New("pdfgrab: page out of range") + ErrUnsupported = errors.New("pdfgrab: unsupported feature") + ErrEncrypted = errors.New("pdfgrab: encrypted PDF (decryption not yet supported)") ) ``` ## Text extraction ```go -doc, _ := pdftable.OpenFile("report.pdf") +doc, _ := pdfgrab.OpenFile("report.pdf") defer doc.Close() page, _ := doc.Page(1) // Words: each Word is a contiguous text run. -words, _ := page.Words(pdftable.DefaultWordOpts()) +words, _ := page.Words(pdfgrab.DefaultWordOpts()) for _, w := range words { fmt.Printf("%-20s @ (%.1f, %.1f) %s %.1fpt\n", w.Text, w.X0, w.Y0, w.FontName, w.FontSize) @@ -201,12 +201,12 @@ for _, w := range words { // ExtractText: all text on the page as one string. Dense (no layout) // joins words with spaces and lines with "\n". -text, _ := page.ExtractText(pdftable.DefaultTextOpts()) +text, _ := page.ExtractText(pdfgrab.DefaultTextOpts()) fmt.Println(text) // Layout-preserving extraction emulates `pdftotext -layout` / pdfplumber's // extract_text(layout=True) — column-aligned output suitable for forms. -opts := pdftable.DefaultTextOpts() +opts := pdfgrab.DefaultTextOpts() opts.Layout = true laid, _ := page.ExtractText(opts) fmt.Println(laid) @@ -220,12 +220,12 @@ pdfplumber's `TableFinder`) and returns one `*Table` per detected table, with cell text already extracted. ```go -doc, _ := pdftable.OpenFile("invoice.pdf") +doc, _ := pdfgrab.OpenFile("invoice.pdf") defer doc.Close() page, _ := doc.Page(1) -settings := pdftable.DefaultTableSettings() -// settings.VerticalStrategy = pdftable.StrategyLinesStrict // ignore rect outlines +settings := pdfgrab.DefaultTableSettings() +// settings.VerticalStrategy = pdfgrab.StrategyLinesStrict // ignore rect outlines tables, _ := page.ExtractTables(settings) for ti, t := range tables { @@ -260,7 +260,7 @@ The four implemented strategies (one per axis, chosen independently): table boundaries are known from layout analysis or manual annotation. -### Side-by-side: pdfplumber → pdftable (lines strategy) +### Side-by-side: pdfplumber → pdfgrab (lines strategy) ```python # Python (pdfplumber) @@ -275,16 +275,16 @@ with pdfplumber.open("invoice.pdf") as pdf: ``` ```go -// Go (pdftable) -import "github.com/hallelx2/pdftable" +// Go (pdfgrab) +import "github.com/hallelx2/pdfgrab" -doc, _ := pdftable.OpenFile("invoice.pdf") +doc, _ := pdfgrab.OpenFile("invoice.pdf") defer doc.Close() page, _ := doc.Page(1) -settings := pdftable.DefaultTableSettings() -settings.VerticalStrategy = pdftable.StrategyLines -settings.HorizontalStrategy = pdftable.StrategyLines +settings := pdfgrab.DefaultTableSettings() +settings.VerticalStrategy = pdfgrab.StrategyLines +settings.HorizontalStrategy = pdfgrab.StrategyLines tables, _ := page.ExtractTables(settings) for _, t := range tables { @@ -294,7 +294,7 @@ for _, t := range tables { } ``` -### Side-by-side: pdfplumber → pdftable (text strategy) +### Side-by-side: pdfplumber → pdfgrab (text strategy) ```python # Python (pdfplumber) — borderless tables @@ -310,14 +310,14 @@ with pdfplumber.open("10k-filing.pdf") as pdf: ``` ```go -// Go (pdftable) -doc, _ := pdftable.OpenFile("10k-filing.pdf") +// Go (pdfgrab) +doc, _ := pdfgrab.OpenFile("10k-filing.pdf") defer doc.Close() page, _ := doc.Page(4) -settings := pdftable.DefaultTableSettings() -settings.VerticalStrategy = pdftable.StrategyText -settings.HorizontalStrategy = pdftable.StrategyText +settings := pdfgrab.DefaultTableSettings() +settings.VerticalStrategy = pdfgrab.StrategyText +settings.HorizontalStrategy = pdfgrab.StrategyText settings.MinWordsVertical = 3 tables, _ := page.ExtractTables(settings) @@ -328,7 +328,7 @@ for _, t := range tables { } ``` -### Side-by-side: pdfplumber → pdftable (explicit strategy) +### Side-by-side: pdfplumber → pdfgrab (explicit strategy) ```python # Python (pdfplumber) — caller-supplied edges @@ -347,14 +347,14 @@ with pdfplumber.open("statement.pdf") as pdf: ``` ```go -// Go (pdftable) -doc, _ := pdftable.OpenFile("statement.pdf") +// Go (pdfgrab) +doc, _ := pdfgrab.OpenFile("statement.pdf") defer doc.Close() page, _ := doc.Page(1) -settings := pdftable.DefaultTableSettings() -settings.VerticalStrategy = pdftable.StrategyExplicit -settings.HorizontalStrategy = pdftable.StrategyExplicit +settings := pdfgrab.DefaultTableSettings() +settings.VerticalStrategy = pdfgrab.StrategyExplicit +settings.HorizontalStrategy = pdfgrab.StrategyExplicit settings.ExplicitVerticalLines = []float64{100, 200, 300, 400} settings.ExplicitHorizontalLines = []float64{600, 650, 700, 720} @@ -371,16 +371,16 @@ Each axis picks its strategy independently. Combinations like row separators but borderless columns) work out of the box: ```go -settings := pdftable.DefaultTableSettings() -settings.VerticalStrategy = pdftable.StrategyText -settings.HorizontalStrategy = pdftable.StrategyLines +settings := pdfgrab.DefaultTableSettings() +settings.VerticalStrategy = pdfgrab.StrategyText +settings.HorizontalStrategy = pdfgrab.StrategyLines tables, _ := page.ExtractTables(settings) ``` The two outputs match cell-for-cell on the parity fixtures (see `testdata/golden/*.tables-text.expected.json` and `*.tables.expected.json` for the regression goldens). Field naming -differs in the obvious places: pdftable returns a slice of `*Table` +differs in the obvious places: pdfgrab returns a slice of `*Table` instead of `Table` objects you have to call `.extract()` on; rows are `[]string` instead of `list[Optional[str]]` (missing cells produce `""` rather than `nil`); and table bboxes use `(X0, Y0, X1, Y1)` PDF @@ -389,32 +389,32 @@ user space rather than pdfplumber's image-space ## CLI -`pdftable` ships a command-line interface that mirrors pdfplumber's +`pdfgrab` ships a command-line interface that mirrors pdfplumber's CLI surface for the operations the library implements: ```sh -go install github.com/hallelx2/pdftable/cmd/pdftable@v0.3.0 +go install github.com/hallelx2/pdfgrab/cmd/pdfgrab@v0.3.0 ``` Usage: ```sh # Extract every table on every page as JSON. -pdftable extract invoice.pdf --tables --format json +pdfgrab extract invoice.pdf --tables --format json # Borderless tables: use the text strategy. -pdftable extract 10k.pdf --tables \ +pdfgrab extract 10k.pdf --tables \ --vertical-strategy text --horizontal-strategy text \ --min-words-vertical 4 # Extract text only (no table detection). -pdftable extract report.pdf --text --format text +pdfgrab extract report.pdf --text --format text # Subset of pages, pretty-printed JSON. -pdftable extract report.pdf --tables --pages 1,3-5 --indent 2 +pdfgrab extract report.pdf --tables --pages 1,3-5 --indent 2 # Caller-supplied edges. -pdftable extract statement.pdf --tables \ +pdfgrab extract statement.pdf --tables \ --vertical-strategy explicit --horizontal-strategy explicit \ --explicit-vertical-lines 100,200,300,400 \ --explicit-horizontal-lines 600,650,700,720 @@ -455,20 +455,20 @@ with pdfplumber.open("report.pdf") as pdf: ``` ```go -// Go (pdftable) -import "github.com/hallelx2/pdftable" +// Go (pdfgrab) +import "github.com/hallelx2/pdfgrab" -doc, _ := pdftable.OpenFile("report.pdf") +doc, _ := pdfgrab.OpenFile("report.pdf") defer doc.Close() page, _ := doc.Page(1) -words, _ := page.Words(pdftable.DefaultWordOpts()) +words, _ := page.Words(pdfgrab.DefaultWordOpts()) for _, w := range words { - // pdftable's Y is PDF user-space (origin bottom-left). The + // pdfgrab's Y is PDF user-space (origin bottom-left). The // pdfplumber-equivalent "top" is page.Height() - w.Y1. fmt.Println(w.Text, w.X0, page.Height()-w.Y1) } -fmt.Println(must(page.ExtractText(pdftable.DefaultTextOpts()))) +fmt.Println(must(page.ExtractText(pdfgrab.DefaultTextOpts()))) ``` Three differences worth noting: @@ -508,7 +508,7 @@ Behaviours that intentionally differ: - **Position precision now matches on the standard 14 fonts; residual drift is limited to embedded fonts that ship no metrics**. pdfplumber uses pdfminer.six's AFM tables for the standard 14 fonts. - pdftable now bundles the same Adobe Core 14 metrics, so a font that + pdfgrab now bundles the same Adobe Core 14 metrics, so a font that omits `/Widths` — which is spec-legal for those 14, and was previously the source of up to ~10pt of word-bbox drift — resolves to real per-glyph widths instead of a flat 500 guess. Common substitute names @@ -545,8 +545,8 @@ Behaviours not yet ported: ## Architecture ``` -pdftable/ -├── pdftable.go // Open / OpenBytes / OpenFile entry points +pdfgrab/ +├── pdfgrab.go // Open / OpenBytes / OpenFile entry points ├── pdf.go // Document interface + implementation ├── page.go // Page interface + implementation ├── char.go // Public Char / Line / Rect / Curve / Objects @@ -558,7 +558,7 @@ pdftable/ ├── geometry.go // BBox helpers: Union, Intersect, Contains, Snap ├── errors.go // Sentinel errors ├── cmd/ -│ └── pdftable/ // Command-line interface (v0.3.0) +│ └── pdfgrab/ // Command-line interface (v0.3.0) │ └── main.go └── internal/ ├── layout/ @@ -572,7 +572,7 @@ pdftable/ └── cmap.go // ToUnicode CMap parser ``` -The public `pdftable` package is small and stable. The `internal/pdf` +The public `pdfgrab` package is small and stable. The `internal/pdf` package owns the interpreter — its types are not exposed because they will evolve as more PDF features are added (Type 3 fonts, vertical writing, more exotic CMaps). @@ -600,7 +600,7 @@ stdlib-only. strategies. - `v0.3.x` — remaining table strategies and CLI (this release): `text` (word-alignment edges), `explicit` (caller-supplied edges), - and a `pdftable` CLI mirroring pdfplumber's surface. + and a `pdfgrab` CLI mirroring pdfplumber's surface. - `v0.4.x` — bundle the standard-14 AFM metrics so word bboxes (and therefore cell text) match pdfplumber on standard fonts. **Done**: the Adobe Core 14 metrics ship, Symbol/ZapfDingbats decode with their own @@ -620,7 +620,7 @@ stdlib-only. ## Repository layout -`pdftable` is a single Go package, so its source lives in the repository +`pdfgrab` is a single Go package, so its source lives in the repository root — that is the import path, and Go keeps `_test.go` files next to the code they cover. @@ -629,7 +629,7 @@ code they cover. | `*.go` | the library | | `internal/pdf/` | content-stream interpreter, fonts, encodings | | `internal/layout/` | edge/intersection/cell geometry | -| `cmd/pdftable/` | CLI | +| `cmd/pdfgrab/` | CLI | | `bench/` | accuracy benchmarks against public datasets — a **separate Go module**, so it never adds a dependency here | | `docs/evaluations/` | dated measurement reports, with their caveats | | `scripts/` | fixture and golden-file generators | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 17cd49e..66b8661 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -pdftable itself is MIT-licensed (see `LICENSE`). It bundles no third-party +pdfgrab itself is MIT-licensed (see `LICENSE`). It bundles no third-party *code*, but several generated data tables in `internal/pdf/` are derived from third-party sources. Those sources and their license terms are reproduced below. diff --git a/bench/README.md b/bench/README.md index 9af9a6f..20172ee 100644 --- a/bench/README.md +++ b/bench/README.md @@ -5,7 +5,7 @@ Accuracy benchmarks against public datasets with published ground truth. These are **not** `go test` benchmarks (those live next to the code as `*_bench_test.go` and measure speed). These measure **correctness** against an external reference, and they are deliberately outside the library's Go -module so their dependencies never reach a consumer of `pdftable`. +module so their dependencies never reach a consumer of `pdfgrab`. Datasets are **not committed** — they are large and separately licensed. Each harness downloads what it needs into a scratch directory. diff --git a/bench/go.mod b/bench/go.mod index e3dfbdb..61e5895 100644 --- a/bench/go.mod +++ b/bench/go.mod @@ -1,12 +1,12 @@ // Separate module on purpose: the benchmark harness must never add a // dependency to the library it measures. Nested modules are excluded from // the parent's ./... , so go build ./... at the repo root ignores this -// entirely and a consumer of pdftable never fetches it. -module github.com/hallelx2/pdftable/bench +// entirely and a consumer of pdfgrab never fetches it. +module github.com/hallelx2/pdfgrab/bench go 1.25.0 -require github.com/hallelx2/pdftable v0.0.0 +require github.com/hallelx2/pdfgrab v0.0.0 require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect @@ -22,4 +22,4 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect ) -replace github.com/hallelx2/pdftable => ../ +replace github.com/hallelx2/pdfgrab => ../ diff --git a/bench/icdar2013/README.md b/bench/icdar2013/README.md index fb7531c..02c688e 100644 --- a/bench/icdar2013/README.md +++ b/bench/icdar2013/README.md @@ -11,7 +11,7 @@ python bench/icdar2013/run.py --limit 5 # quick check while iterating python bench/icdar2013/run.py --diag # detection vs structure breakdown ``` -The dataset (~12 MB) downloads to `~/.cache/pdftable-bench` (override with +The dataset (~12 MB) downloads to `~/.cache/pdfgrab-bench` (override with `PDFTABLE_BENCH_DIR`). It is not committed. ## Metric: adjacency relations @@ -52,6 +52,6 @@ Latest results: [`docs/evaluations/`](../../docs/evaluations/). | | | | --- | --- | | `run.py` | fetches the dataset, builds the extractor, runs everything | -| `extract.go` | dumps every table pdftable finds as JSON; built as its own module so the benchmark adds no dependency to the library | +| `extract.go` | dumps every table pdfgrab finds as JSON; built as its own module so the benchmark adds no dependency to the library | | `score.py` | parses ground-truth XML, computes precision/recall/F1 | | `diag.py` | separates detection failures from structure failures | diff --git a/bench/icdar2013/extract.go b/bench/icdar2013/extract.go index a6f7cac..137c9ce 100644 --- a/bench/icdar2013/extract.go +++ b/bench/icdar2013/extract.go @@ -1,4 +1,4 @@ -// Emit every table pdftable finds in a PDF, as JSON, for benchmarking. +// Emit every table pdfgrab finds in a PDF, as JSON, for benchmarking. package main import ( @@ -8,7 +8,7 @@ import ( "os" "strconv" - "github.com/hallelx2/pdftable" + "github.com/hallelx2/pdfgrab" ) type tableOut struct { @@ -30,7 +30,7 @@ func main() { `JSON of per-page explicit edges: {"1":{"v":[..],"h":[..]}}`) flag.Parse() - doc, err := pdftable.OpenFile(flag.Arg(0)) + doc, err := pdfgrab.OpenFile(flag.Arg(0)) if err != nil { // A file we cannot open scores zero rather than aborting the run; // the harness needs a result for every document. @@ -40,37 +40,37 @@ func main() { } defer doc.Close() - mk := func(v, h pdftable.TableStrategy) pdftable.TableSettings { - s := pdftable.DefaultTableSettings() + mk := func(v, h pdfgrab.TableStrategy) pdfgrab.TableSettings { + s := pdfgrab.DefaultTableSettings() s.VerticalStrategy, s.HorizontalStrategy = v, h s.MergeSplitTokens = *merge return s } - lines := mk(pdftable.StrategyLines, pdftable.StrategyLines) - text := mk(pdftable.StrategyText, pdftable.StrategyText) + lines := mk(pdfgrab.StrategyLines, pdfgrab.StrategyLines) + text := mk(pdfgrab.StrategyText, pdfgrab.StrategyText) // "mixed" is the booktabs case: horizontal rules give the rows, word // alignment gives the columns. - mixed := mk(pdftable.StrategyText, pdftable.StrategyLines) - auto := mk(pdftable.StrategyAuto, pdftable.StrategyAuto) + mixed := mk(pdfgrab.StrategyText, pdfgrab.StrategyLines) + auto := mk(pdfgrab.StrategyAuto, pdfgrab.StrategyAuto) - var attempts []pdftable.TableSettings + var attempts []pdfgrab.TableSettings switch *strategy { case "text": - attempts = []pdftable.TableSettings{text} + attempts = []pdfgrab.TableSettings{text} case "mixed": - attempts = []pdftable.TableSettings{mixed} + attempts = []pdfgrab.TableSettings{mixed} case "auto": - attempts = []pdftable.TableSettings{auto} + attempts = []pdfgrab.TableSettings{auto} case "lines-then-mixed": - attempts = []pdftable.TableSettings{lines, mixed} + attempts = []pdfgrab.TableSettings{lines, mixed} case "fallback": - attempts = []pdftable.TableSettings{lines, text} + attempts = []pdfgrab.TableSettings{lines, text} default: - attempts = []pdftable.TableSettings{lines} + attempts = []pdfgrab.TableSettings{lines} } // Oracle mode: the caller supplies the row/column boundaries and - // pdftable only fills the cells. This is exactly the shape of the + // pdfgrab only fills the cells. This is exactly the shape of the // hybrid a layout model would drive — and fed GROUND-TRUTH edges it // measures the ceiling that hybrid can reach: how good extraction gets // if detection and gridding were solved perfectly. @@ -93,7 +93,7 @@ func main() { if !ok || len(e.V) < 2 || len(e.H) < 2 { continue } - s := mk(pdftable.StrategyExplicit, pdftable.StrategyExplicit) + s := mk(pdfgrab.StrategyExplicit, pdfgrab.StrategyExplicit) s.ExplicitVerticalLines = e.V s.ExplicitHorizontalLines = e.H if tables, err := p.ExtractTables(s); err == nil { diff --git a/bench/icdar2013/oracle.py b/bench/icdar2013/oracle.py index 5c3308f..356bd20 100644 --- a/bench/icdar2013/oracle.py +++ b/bench/icdar2013/oracle.py @@ -1,7 +1,7 @@ """Measure the CEILING of a layout-model hybrid. -Feeds pdftable the ground-truth row and column boundaries — the output a -perfect layout model would produce — and scores what comes back. pdftable +Feeds pdfgrab the ground-truth row and column boundaries — the output a +perfect layout model would produce — and scores what comes back. pdfgrab then does only the part it is good at: filling cells from the text layer and reporting exact coordinates. @@ -27,7 +27,7 @@ yields exactly ncols+1 lines, which is what a grid actually is. Ground-truth boxes are in PDF points with the origin bottom-left — the -same space pdftable reports — verified against pdfplumber word positions +same space pdfgrab reports — verified against pdfplumber word positions (GT y1=619.0 vs word y0=616.9 on eu-002; a box-vs-glyph difference, not a flip). No conversion is needed. """ @@ -205,9 +205,9 @@ def main() -> int: print("-" * 74) for name in variants: p, r, f = prf(*totals[name]) - print(f"{'pdftable + ' + name:<40} {p:>10.3f} {r:>10.3f} {f:>10.3f}") + print(f"{'pdfgrab + ' + name:<40} {p:>10.3f} {r:>10.3f} {f:>10.3f}") print(f"\ndocuments scored: {scored_docs} (single-region pages only)") - print("This is the ceiling a perfect layout model could hand pdftable.") + print("This is the ceiling a perfect layout model could hand pdfgrab.") return 0 diff --git a/bench/icdar2013/run.py b/bench/icdar2013/run.py index 7b578f7..cbe6589 100644 --- a/bench/icdar2013/run.py +++ b/bench/icdar2013/run.py @@ -4,10 +4,10 @@ python bench/icdar2013/run.py Downloads the dataset (~12 MB) into a scratch directory, builds the Go -extractor, scores pdftable against the ground truth, and prints a result +extractor, scores pdfgrab against the ground truth, and prints a result table. pdfplumber is scored alongside as a reference point — the question "is 0.36 good?" is unanswerable without a baseline, and pdfplumber is the -implementation pdftable is a port of. +implementation pdfgrab is a port of. Pass --limit N to score only the first N documents while iterating. """ @@ -38,7 +38,7 @@ def scratch() -> str: d = os.environ.get("PDFTABLE_BENCH_DIR") or os.path.join( - os.path.expanduser("~"), ".cache", "pdftable-bench" + os.path.expanduser("~"), ".cache", "pdfgrab-bench" ) os.makedirs(d, exist_ok=True) return d @@ -73,10 +73,10 @@ def build_extractor(dest: str) -> str: capture_output=True) subprocess.run( ["go", "mod", "edit", "-replace", - f"github.com/hallelx2/pdftable={REPO.replace(os.sep, '/')}"], + f"github.com/hallelx2/pdfgrab={REPO.replace(os.sep, '/')}"], cwd=mod, check=True, capture_output=True) subprocess.run( - ["go", "mod", "edit", "-require", "github.com/hallelx2/pdftable@v0.0.0"], + ["go", "mod", "edit", "-require", "github.com/hallelx2/pdfgrab@v0.0.0"], cwd=mod, check=True, capture_output=True) subprocess.run(["go", "mod", "tidy"], cwd=mod, check=True, capture_output=True) subprocess.run(["go", "build", "-o", exe, "."], cwd=mod, check=True) diff --git a/bench/icdar2013/score.py b/bench/icdar2013/score.py index 9e26dc6..83c0535 100644 --- a/bench/icdar2013/score.py +++ b/bench/icdar2013/score.py @@ -101,7 +101,7 @@ def prf(correct: int, ndet: int, ngt: int) -> tuple[float, float, float]: return p, r, f -def run_pdftable(exe: str, pdf: str, strategy: str, merge: bool) -> Counter: +def run_pdfgrab(exe: str, pdf: str, strategy: str, merge: bool) -> Counter: cmd = [exe, "-strategy", strategy] if merge: cmd.append("-merge") @@ -153,9 +153,9 @@ def main() -> int: print(f"scoring {len(pairs)} documents\n", flush=True) systems = { - "pdftable (lines)": lambda p: run_pdftable(exe, p, "lines", False), - "pdftable (AUTO)": lambda p: run_pdftable(exe, p, "auto", False), - "pdftable (AUTO +merge)": lambda p: run_pdftable(exe, p, "auto", True), + "pdfgrab (lines)": lambda p: run_pdfgrab(exe, p, "lines", False), + "pdfgrab (AUTO)": lambda p: run_pdfgrab(exe, p, "auto", False), + "pdfgrab (AUTO +merge)": lambda p: run_pdfgrab(exe, p, "auto", True), "pdfplumber (lines)": lambda p: run_pdfplumber(p, "lines"), } totals = {k: [0, 0, 0] for k in systems} @@ -175,7 +175,7 @@ def main() -> int: for name in systems: p, r, f = prf(*totals[name]) print(f"{name:<28} {p:>10.3f} {r:>10.3f} {f:>10.3f}") - print(f"\nground-truth relations: {totals['pdftable (lines)'][2]}") + print(f"\nground-truth relations: {totals['pdfgrab (lines)'][2]}") return 0 diff --git a/cell_edges_test.go b/cell_edges_test.go index f4a1f2e..7df96eb 100644 --- a/cell_edges_test.go +++ b/cell_edges_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import "testing" diff --git a/char.go b/char.go index 251a52f..b0d485b 100644 --- a/char.go +++ b/char.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. See LICENSE file in the project root. -package pdftable +package pdfgrab // This file defines the public per-page object types. Naming mirrors // pdfplumber's char/line/rect/curve objects, but field names follow Go diff --git a/clustering.go b/clustering.go index 0431bd8..e57f42a 100644 --- a/clustering.go +++ b/clustering.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "math" diff --git a/clustering_test.go b/clustering_test.go index a3b41ab..9747b4d 100644 --- a/clustering_test.go +++ b/clustering_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "reflect" diff --git a/cmd/pdftable/main.go b/cmd/pdfgrab/main.go similarity index 89% rename from cmd/pdftable/main.go rename to cmd/pdfgrab/main.go index 5508d5d..a7e6de2 100644 --- a/cmd/pdftable/main.go +++ b/cmd/pdfgrab/main.go @@ -1,13 +1,13 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -// cmd/pdftable is the command-line interface to the pdftable library. -// It mirrors pdfplumber's CLI surface for the operations pdftable +// cmd/pdfgrab is the command-line interface to the pdfgrab library. +// It mirrors pdfplumber's CLI surface for the operations pdfgrab // implements: extract text, extract tables, dump page geometry. // // Usage: // -// pdftable extract [flags] +// pdfgrab extract [flags] // // Flags (extract subcommand): // @@ -28,7 +28,7 @@ // --explicit-horizontal-lines Comma-separated floats; required when horizontal-strategy=explicit. // --indent Int; JSON pretty-printing indent. 0 = compact. // -// The CLI uses the standard library `flag` package and the `pdftable` +// The CLI uses the standard library `flag` package and the `pdfgrab` // public API only — no third-party dependencies. package main @@ -41,9 +41,18 @@ import ( "strconv" "strings" - "github.com/hallelx2/pdftable" + "github.com/hallelx2/pdfgrab" ) +// version is the CLI's reported version. The release workflow injects +// the tag via -ldflags "-X main.version=$GITHUB_REF_NAME"; a plain +// `go build` leaves it as "dev". +// +// It is deliberately NOT a hardcoded literal. It was one until v0.5.0, +// and it silently reported v0.3.0 for the whole of v0.4.0 because +// nothing tied the string to the tag. +var version = "dev" + func main() { if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, err) @@ -66,7 +75,7 @@ func run(args []string, stdout, stderr io.Writer) error { printUsage(stdout) return nil case "version", "-v", "--version": - fmt.Fprintln(stdout, "pdftable v0.3.0") + fmt.Fprintln(stdout, "pdfgrab "+version) return nil default: printUsage(stderr) @@ -76,14 +85,14 @@ func run(args []string, stdout, stderr io.Writer) error { // printUsage prints the top-level usage string. func printUsage(w io.Writer) { - fmt.Fprintln(w, `pdftable — extract text and tables from PDFs + fmt.Fprintln(w, `pdfgrab — extract text and tables from PDFs USAGE: - pdftable extract [flags] - pdftable version - pdftable help + pdfgrab extract [flags] + pdfgrab version + pdfgrab help -EXTRACT FLAGS (run 'pdftable extract --help' for full list): +EXTRACT FLAGS (run 'pdfgrab extract --help' for full list): --pages 1,3-5 Pages to process (default: all). --tables Output detected tables. --text Output extracted text (mutually exclusive with --tables). @@ -91,7 +100,7 @@ EXTRACT FLAGS (run 'pdftable extract --help' for full list): --vertical-strategy S "lines" | "lines_strict" | "text" | "explicit". --horizontal-strategy S Same set, default "lines". -Documentation: https://github.com/hallelx2/pdftable`) +Documentation: https://github.com/hallelx2/pdfgrab`) } // extractFlags is the parsed flag set for the extract subcommand. @@ -163,14 +172,14 @@ func runExtract(args []string, stdout, stderr io.Writer) error { } if !f.tables && !f.text { // Default to --tables when neither is specified. Mirrors the - // pdftable library's primary use case. + // pdfgrab library's primary use case. f.tables = true } if f.format != "json" && f.format != "text" { return fmt.Errorf("--format must be json or text, got %q", f.format) } - doc, err := pdftable.OpenFile(path) + doc, err := pdfgrab.OpenFile(path) if err != nil { return err } @@ -256,10 +265,10 @@ func parsePages(spec string, total int) ([]int, error) { // buildSettings translates the parsed flag set into a TableSettings. // It also parses the explicit-line coordinate strings. -func buildSettings(f extractFlags) (pdftable.TableSettings, error) { - s := pdftable.DefaultTableSettings() - s.VerticalStrategy = pdftable.TableStrategy(f.verticalStrategy) - s.HorizontalStrategy = pdftable.TableStrategy(f.horizontalStrategy) +func buildSettings(f extractFlags) (pdfgrab.TableSettings, error) { + s := pdfgrab.DefaultTableSettings() + s.VerticalStrategy = pdfgrab.TableStrategy(f.verticalStrategy) + s.HorizontalStrategy = pdfgrab.TableStrategy(f.horizontalStrategy) s.SnapTolerance = f.snapTolerance s.JoinTolerance = f.joinTolerance s.EdgeMinLength = f.edgeMinLength @@ -289,7 +298,7 @@ func buildSettings(f extractFlags) (pdftable.TableSettings, error) { // reorderFlagsLast moves positional arguments to the end of the slice // so the standard library flag package's "stop at first non-flag" // behaviour doesn't get in the way of pdfplumber-style invocations -// like `pdftable extract file.pdf --tables`. +// like `pdfgrab extract file.pdf --tables`. // // Heuristic: a token is a flag if it starts with "-" or "--". Flags // of the form "--foo=val" carry their value inline. Flags whose @@ -359,7 +368,7 @@ func parseFloatList(spec string) ([]float64, error) { return out, nil } -// tablesOutput is the JSON shape emitted by `pdftable extract +// tablesOutput is the JSON shape emitted by `pdfgrab extract // --tables`. We deliberately mirror pdfplumber's `to_json` schema // where it overlaps: one entry per page, each carrying the page // dimensions and a list of tables. Each table carries the row grid, @@ -383,7 +392,7 @@ type tableOutput struct { // emitTables runs ExtractTables on each requested page and writes the // aggregated result in the requested format. -func emitTables(doc pdftable.Document, pages []int, settings pdftable.TableSettings, f extractFlags, w io.Writer) error { +func emitTables(doc pdfgrab.Document, pages []int, settings pdfgrab.TableSettings, f extractFlags, w io.Writer) error { out := tablesOutput{Pages: make([]pageTablesOutput, 0, len(pages))} for _, n := range pages { page, err := doc.Page(n) @@ -441,7 +450,7 @@ func emitTables(doc pdftable.Document, pages []int, settings pdftable.TableSetti return enc.Encode(out) } -// textOutput is the JSON shape for `pdftable extract --text`. One +// textOutput is the JSON shape for `pdfgrab extract --text`. One // entry per page; text is the dense extract-text output. type textOutput struct { Pages []pageTextOutput `json:"pages"` @@ -458,14 +467,14 @@ type pageTextOutput struct { // aggregated result in the requested format. --format text emits the // text verbatim with a form-feed (\f) between pages, mirroring // `pdftotext` and pdfplumber's --format text behaviour. -func emitText(doc pdftable.Document, pages []int, f extractFlags, w io.Writer) error { +func emitText(doc pdfgrab.Document, pages []int, f extractFlags, w io.Writer) error { out := textOutput{Pages: make([]pageTextOutput, 0, len(pages))} for _, n := range pages { page, err := doc.Page(n) if err != nil { return err } - text, err := page.ExtractText(pdftable.DefaultTextOpts()) + text, err := page.ExtractText(pdfgrab.DefaultTextOpts()) if err != nil { return fmt.Errorf("page %d: %w", n, err) } diff --git a/cmd/pdftable/main_test.go b/cmd/pdfgrab/main_test.go similarity index 91% rename from cmd/pdftable/main_test.go rename to cmd/pdfgrab/main_test.go index 32d50ce..a7c2917 100644 --- a/cmd/pdftable/main_test.go +++ b/cmd/pdfgrab/main_test.go @@ -231,14 +231,23 @@ func TestRun_UnknownSubcommand(t *testing.T) { } } -// TestRun_Version asserts the `version` subcommand emits something -// that mentions v0.3.0. +// TestRun_Version asserts the `version` subcommand names the tool and +// reports whatever version was injected at build time. +// +// It deliberately does NOT assert a version literal. The previous form +// pinned "v0.3.0", so the CLI kept reporting v0.3.0 through the entire +// v0.4.0 release and the test agreed with it. A test that has to be +// hand-edited every release will drift the moment someone forgets. func TestRun_Version(t *testing.T) { var stdout, stderr bytes.Buffer if err := run([]string{"version"}, &stdout, &stderr); err != nil { t.Fatalf("run version: %v", err) } - if !strings.Contains(stdout.String(), "v0.3.0") { - t.Errorf("output missing version: %q", stdout.String()) + got := strings.TrimSpace(stdout.String()) + if !strings.HasPrefix(got, "pdfgrab ") { + t.Errorf("version output does not name the tool: %q", got) + } + if strings.TrimPrefix(got, "pdfgrab ") == "" { + t.Errorf("version output carries no version: %q", got) } } diff --git a/docs/evaluations/2026-08-02-font-metrics-and-table-fidelity.md b/docs/evaluations/2026-08-02-font-metrics-and-table-fidelity.md index f2ede0c..d8c4cbc 100644 --- a/docs/evaluations/2026-08-02-font-metrics-and-table-fidelity.md +++ b/docs/evaluations/2026-08-02-font-metrics-and-table-fidelity.md @@ -20,7 +20,7 @@ ### 1. Flat 500 width for the standard 14 fonts PDF 1.7 §9.6.2.2 lets the 14 standard fonts omit `/Widths`; a consumer is -expected to already know their metrics. pdftable did not, and fell through +expected to already know their metrics. pdfgrab did not, and fell through to a flat 500/1000 guess for every glyph — `i` (222) and `m` (833) got identical widths, and the error accumulated along each line. @@ -35,7 +35,7 @@ Both fonts carry their own built-in encoding and neither declares `0x61` decoded as `a` rather than `alpha` — a *text*-correctness failure, not a metrics one. Any document using Symbol for Greek extracted nonsense. -pdfplumber 0.11.9 still exhibits this; pdftable now does not. +pdfplumber 0.11.9 still exhibits this; pdfgrab now does not. ### 3. Glyph boxes sat `descent × size` too high @@ -71,7 +71,7 @@ made it dangerous. A missing value is detectable downstream; a plausible wrong sign is not. **pdfplumber has the same defect.** On the same row it returns `16,048` -without the closing paren. pdftable is now strictly better than its +without the closing paren. pdfgrab is now strictly better than its reference here. ### 5. Small type over-merged into single runs @@ -91,8 +91,8 @@ was filed as a defect. **It is not** — that gap is in the source document. The rendered page shows it, and poppler extracts it identically. 3M's filing agent typeset it that way, which is common in SEC EDGAR documents. -The error was methodological: the original comparison was pdftable's table -output against pdftable's *own* text output. Internal agreement proves the +The error was methodological: the original comparison was pdfgrab's table +output against pdfgrab's *own* text output. Internal agreement proves the two code paths disagree, not which one is right. Correcting it required an external reference — a rendered page and a second extractor. diff --git a/docs/evaluations/2026-08-02-icdar2013-table-structure.md b/docs/evaluations/2026-08-02-icdar2013-table-structure.md index 13ecccd..0a88220 100644 --- a/docs/evaluations/2026-08-02-icdar2013-table-structure.md +++ b/docs/evaluations/2026-08-02-icdar2013-table-structure.md @@ -10,10 +10,10 @@ | system | precision | recall | F1 | | --- | --- | --- | --- | -| pdftable (`lines`) | 0.865 | 0.229 | **0.362** | +| pdfgrab (`lines`) | 0.865 | 0.229 | **0.362** | | pdfplumber (`lines`) | 0.868 | 0.235 | **0.370** | -| pdftable (fallback `lines`→`text`) | 0.223 | 0.557 | 0.318 | -| pdftable (fallback + `MergeSplitTokens`) | 0.471 | 0.275 | 0.347 | +| pdfgrab (fallback `lines`→`text`) | 0.223 | 0.557 | 0.318 | +| pdfgrab (fallback + `MergeSplitTokens`) | 0.471 | 0.275 | 0.347 | | pdfplumber (`text`) | 0.167 | 0.679 | 0.267 | ## Two findings @@ -22,7 +22,7 @@ 0.362 vs 0.370, with near-identical precision (0.865 vs 0.868). Everything before this validated *text* fidelity — word positions, glyph widths, -signs. This is the first measurement of *table* behaviour, and pdftable +signs. This is the first measurement of *table* behaviour, and pdfgrab tracks its reference implementation there too. ### 2. The bottleneck is detection, not structure @@ -75,7 +75,7 @@ replacing either: - **layout/VLM model → table region and row/column structure** (attacks the 0.229 recall) -- **pdftable text layer → cell contents and coordinates** (keeps exact +- **pdfgrab text layer → cell contents and coordinates** (keeps exact values and citation geometry, which a generative model cannot provide) Two cheaper wins should be tried first, since that is where the points diff --git a/docs/evaluations/2026-08-02-strategy-auto-negative-result.md b/docs/evaluations/2026-08-02-strategy-auto-negative-result.md index ae88e63..662b23b 100644 --- a/docs/evaluations/2026-08-02-strategy-auto-negative-result.md +++ b/docs/evaluations/2026-08-02-strategy-auto-negative-result.md @@ -9,7 +9,7 @@ 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 +28 of 125 documents (22%) where pdfgrab 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. @@ -32,9 +32,9 @@ holds. | 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 | +| pdfgrab (`lines`) | 0.865 | 0.229 | **0.362** | +| pdfgrab (`auto`) | 0.797 | 0.231 | **0.358** | +| pdfgrab (`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 @@ -94,7 +94,7 @@ 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 +- **pdfgrab text layer → cell contents and coordinates**, which stays exact and keeps citation geometry ## Reproduce diff --git a/docs/evaluations/2026-08-03-hybrid-ceiling-oracle-boundaries.md b/docs/evaluations/2026-08-03-hybrid-ceiling-oracle-boundaries.md index 93a3830..042f6eb 100644 --- a/docs/evaluations/2026-08-03-hybrid-ceiling-oracle-boundaries.md +++ b/docs/evaluations/2026-08-03-hybrid-ceiling-oracle-boundaries.md @@ -1,4 +1,4 @@ -# The hybrid ceiling — pdftable with perfect table boundaries +# The hybrid ceiling — pdfgrab with perfect table boundaries **Date:** 2026-08-03 **Commit:** `0b39b95` @@ -9,21 +9,21 @@ | system | precision | recall | F1 | | --- | --- | --- | --- | -| pdftable, current best (`lines`) | 0.865 | 0.229 | **0.362** | -| **pdftable + ORACLE boundaries** | **0.948** | **0.922** | **0.935** | -| pdftable + oracle + `MergeSplitTokens` | 0.806 | 0.660 | 0.726 | +| pdfgrab, current best (`lines`) | 0.865 | 0.229 | **0.362** | +| **pdfgrab + ORACLE boundaries** | **0.948** | **0.922** | **0.935** | +| pdfgrab + oracle + `MergeSplitTokens` | 0.806 | 0.660 | 0.726 | 107 documents (single-region pages). ## What it means -**0.362 → 0.935.** Given the right grid, pdftable extracts almost perfectly. +**0.362 → 0.935.** Given the right grid, pdfgrab extracts almost perfectly. So essentially the entire gap in the end-to-end score is **table structure**, not text extraction. The cell-filling, the coordinates, the text fidelity — all the work of the last two days — is not the limiting factor. Finding the rows and columns is. That is a clear verdict on the hybrid: **a layout model that outputs row/column structure is worth deploying.** It converts almost the whole gap. -It also confirms the division of labour. pdftable keeps what a generative model cannot give: exact cell text and exact coordinates for citation highlighting. The model supplies only geometry. +It also confirms the division of labour. pdfgrab keeps what a generative model cannot give: exact cell text and exact coordinates for citation highlighting. The model supplies only geometry. ## `MergeSplitTokens` must be OFF when boundaries come from a model @@ -36,16 +36,16 @@ It also confirms the division of labour. pdftable keeps what a generative model No new dependency and no HTTP client in the library: ```go -s := pdftable.DefaultTableSettings() -s.VerticalStrategy = pdftable.StrategyExplicit -s.HorizontalStrategy = pdftable.StrategyExplicit +s := pdfgrab.DefaultTableSettings() +s.VerticalStrategy = pdfgrab.StrategyExplicit +s.HorizontalStrategy = pdfgrab.StrategyExplicit s.ExplicitVerticalLines = colBoundaries // from the layout model s.ExplicitHorizontalLines = rowBoundaries s.MergeSplitTokens = false // see above tables, _ := page.ExtractTables(s) ``` -The caller owns the model call. pdftable stays deterministic and offline. +The caller owns the model call. pdfgrab stays deterministic and offline. ## Two harness bugs, both worth recording @@ -56,7 +56,7 @@ The first version of this experiment reported **0.119 F1 with perfect input** Fixing (1) moved 0.119 → 0.782; fixing (2) moved 0.782 → 0.935. -A third suspicion turned out to be unfounded: the ground-truth Y origin was checked against pdfplumber word positions and is bottom-left, the same space pdftable reports (GT `y1=619.0` vs word `y0=616.9` on `eu-002` — a box-versus-glyph difference, not a flip). No conversion needed. +A third suspicion turned out to be unfounded: the ground-truth Y origin was checked against pdfplumber word positions and is bottom-left, the same space pdfgrab reports (GT `y1=619.0` vs word `y0=616.9` on `eu-002` — a box-versus-glyph difference, not a flip). No conversion needed. **The lesson is the same one from the font work:** a measurement that disagrees violently with expectation is far more likely to be a broken measurement than a broken system. Both times, checking the harness against a known-good case found the fault in the harness. @@ -67,5 +67,5 @@ Single-region pages only — 107 of 125 documents. Pages carrying several tables ## Reproduce ```sh -python bench/icdar2013/oracle.py ~/.cache/pdftable-bench/ICDAR-2013-Table-Competition-Corrected +python bench/icdar2013/oracle.py ~/.cache/pdfgrab-bench/ICDAR-2013-Table-Competition-Corrected ``` diff --git a/errors.go b/errors.go index 5f5c2b0..b3b9613 100644 --- a/errors.go +++ b/errors.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. See LICENSE file in the project root. -package pdftable +package pdfgrab import "errors" @@ -19,21 +19,21 @@ var ( // ErrInvalidPDF is returned by Open / OpenBytes / OpenFile when the // input bytes can't be parsed as a PDF. The underlying pdfcpu error // is wrapped so callers can still inspect the details with errors.As. - ErrInvalidPDF = errors.New("pdftable: invalid PDF") + ErrInvalidPDF = errors.New("pdfgrab: invalid PDF") // ErrPageOutOfRange is returned by Document.Page when n is < 1 or // > NumPages(). The PDF page index is 1-based, matching pdfplumber. - ErrPageOutOfRange = errors.New("pdftable: page out of range") + ErrPageOutOfRange = errors.New("pdfgrab: page out of range") // ErrUnsupported is returned when we hit a PDF feature this library // does not yet implement (e.g. an exotic CMap, an unsupported XObject // subtype, vertical writing). The error string names the feature. - ErrUnsupported = errors.New("pdftable: unsupported feature") + ErrUnsupported = errors.New("pdfgrab: unsupported feature") // ErrEncrypted is returned when the PDF is encrypted and we can't // decrypt it with the empty password. Full encryption support is // out of scope for the initial release — callers that need it can // pre-decrypt with pdfcpu's api.Decrypt and feed the cleaned bytes // to OpenBytes. - ErrEncrypted = errors.New("pdftable: encrypted PDF (decryption not yet supported)") + ErrEncrypted = errors.New("pdfgrab: encrypted PDF (decryption not yet supported)") ) diff --git a/examples/extract_tables/main.go b/examples/extract_tables/main.go index 86ea48d..391f03a 100644 --- a/examples/extract_tables/main.go +++ b/examples/extract_tables/main.go @@ -21,7 +21,7 @@ import ( "log" "os" - "github.com/hallelx2/pdftable" + "github.com/hallelx2/pdfgrab" ) func main() { @@ -31,7 +31,7 @@ func main() { } path := os.Args[1] - doc, err := pdftable.OpenFile(path) + doc, err := pdfgrab.OpenFile(path) if err != nil { log.Fatalf("OpenFile %s: %v", path, err) } @@ -42,10 +42,10 @@ func main() { log.Fatalf("Page(1): %v", err) } - settings := pdftable.DefaultTableSettings() + settings := pdfgrab.DefaultTableSettings() // Uncomment to ignore Rect outlines (filled cell backgrounds // that aren't real row boundaries): - // settings.VerticalStrategy = pdftable.StrategyLinesStrict + // settings.VerticalStrategy = pdfgrab.StrategyLinesStrict tables, err := page.ExtractTables(settings) if err != nil { diff --git a/finder.go b/finder.go index 73c1b46..21949b7 100644 --- a/finder.go +++ b/finder.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab // finder.go is the Go port of pdfplumber/table.py's TableFinder. The // algorithm runs in four stages, each implemented as a pure function @@ -27,7 +27,7 @@ package pdftable // then-left-to-right. // // Coordinate system note: pdfplumber operates in image space (Y growing -// DOWN). pdftable uses PDF user space (Y growing UP). The intersection +// DOWN). pdfgrab uses PDF user space (Y growing UP). The intersection // algorithm is invariant under that flip — "below" in image space is // "below" in user space if we substitute "lower Y" — but the wording in // pdfplumber's source talks about "directly below and directly right". @@ -43,7 +43,7 @@ import ( "fmt" "sort" - "github.com/hallelx2/pdftable/internal/layout" + "github.com/hallelx2/pdfgrab/internal/layout" ) // Intersection records one crossing point: an (x, y) tuple plus the @@ -882,7 +882,7 @@ func ensureSupportedStrategies(s TableSettings) error { // the "explicit" strategy on an axis but supplies fewer than two // coordinates. pdfplumber raises ValueError with the same message. func errExplicitNeedsTwo(axis string) error { - return fmt.Errorf("pdftable: %s_strategy=%q requires at least two coordinates in Explicit%sLines", + return fmt.Errorf("pdfgrab: %s_strategy=%q requires at least two coordinates in Explicit%sLines", axis, StrategyExplicit, axisFieldName(axis)) } diff --git a/finder_bench_test.go b/finder_bench_test.go index 844e2b5..5ecdc40 100644 --- a/finder_bench_test.go +++ b/finder_bench_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab // finder_bench_test.go pins the performance characteristics of the // cell-finding pipeline. The dense-grid cases are a regression guard @@ -20,7 +20,7 @@ import ( "testing" "time" - "github.com/hallelx2/pdftable/internal/layout" + "github.com/hallelx2/pdfgrab/internal/layout" ) // denseGridEdges builds the edge set for an n x n cell lattice: diff --git a/finder_text.go b/finder_text.go index da1548d..2185433 100644 --- a/finder_text.go +++ b/finder_text.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab // finder_text.go implements the "text" and "explicit" edge-derivation // strategies that complement the "lines" / "lines_strict" strategies in @@ -28,7 +28,7 @@ package pdftable // page's bbox. // // Coordinate system: pdfplumber works in image space (Y growing down, -// "top" = smaller Y). pdftable uses PDF user space (Y growing up). Two +// "top" = smaller Y). pdfgrab uses PDF user space (Y growing up). Two // translations matter: // // - pdfplumber's "top" is our Y1 (the visually-upper edge of a word's @@ -43,7 +43,7 @@ import ( "math" "sort" - "github.com/hallelx2/pdftable/internal/layout" + "github.com/hallelx2/pdfgrab/internal/layout" ) // wordEdgeTolerance is the per-axis tolerance used when clustering @@ -279,11 +279,11 @@ func explicitVerticalEdges(xs []float64, pageY0, pageY1 float64) []layout.Edge { out := make([]layout.Edge, 0, len(xs)) for _, x := range xs { if math.IsNaN(x) || math.IsInf(x, 0) { - log.Printf("pdftable: explicit vertical line %v ignored (non-finite)", x) + log.Printf("pdfgrab: explicit vertical line %v ignored (non-finite)", x) continue } if pageY1 <= pageY0 { - log.Printf("pdftable: explicit vertical line %v ignored (page height is zero)", x) + log.Printf("pdfgrab: explicit vertical line %v ignored (page height is zero)", x) continue } out = append(out, layout.Edge{ @@ -308,11 +308,11 @@ func explicitHorizontalEdges(ys []float64, pageX0, pageX1 float64) []layout.Edge out := make([]layout.Edge, 0, len(ys)) for _, y := range ys { if math.IsNaN(y) || math.IsInf(y, 0) { - log.Printf("pdftable: explicit horizontal line %v ignored (non-finite)", y) + log.Printf("pdfgrab: explicit horizontal line %v ignored (non-finite)", y) continue } if pageX1 <= pageX0 { - log.Printf("pdftable: explicit horizontal line %v ignored (page width is zero)", y) + log.Printf("pdfgrab: explicit horizontal line %v ignored (page width is zero)", y) continue } out = append(out, layout.Edge{ diff --git a/fonts_fixture_test.go b/fonts_fixture_test.go index 4e6d779..0c33b18 100644 --- a/fonts_fixture_test.go +++ b/fonts_fixture_test.go @@ -1,13 +1,13 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable_test +package pdfgrab_test import ( "strings" "testing" - "github.com/hallelx2/pdftable" + "github.com/hallelx2/pdfgrab" ) // The fixtures under testdata/fonts deliberately have NO pdfplumber @@ -18,9 +18,9 @@ import ( // // Regenerate the PDFs with: python scripts/gen_font_fixtures.py -func openPage(t *testing.T, path string, n int) pdftable.Page { +func openPage(t *testing.T, path string, n int) pdfgrab.Page { t.Helper() - doc, err := pdftable.OpenFile(path) + doc, err := pdfgrab.OpenFile(path) if err != nil { t.Fatalf("OpenFile(%s): %v", path, err) } @@ -43,7 +43,7 @@ func openPage(t *testing.T, path string, n int) pdftable.Page { func TestSymbolFixtureDecoding(t *testing.T) { t.Run("Symbol", func(t *testing.T) { p := openPage(t, "testdata/fonts/symbol.pdf", 1) - text, err := p.ExtractText(pdftable.DefaultTextOpts()) + text, err := p.ExtractText(pdfgrab.DefaultTextOpts()) if err != nil { t.Fatalf("ExtractText: %v", err) } @@ -62,7 +62,7 @@ func TestSymbolFixtureDecoding(t *testing.T) { t.Run("ZapfDingbats", func(t *testing.T) { p := openPage(t, "testdata/fonts/symbol.pdf", 2) - text, err := p.ExtractText(pdftable.DefaultTextOpts()) + text, err := p.ExtractText(pdfgrab.DefaultTextOpts()) if err != nil { t.Fatalf("ExtractText: %v", err) } @@ -89,7 +89,7 @@ func TestSymbolFixtureDecoding(t *testing.T) { // silently corrupt text in any document that happens to use the name. func TestDifferencesFixtureDecoding(t *testing.T) { p := openPage(t, "testdata/fonts/differences.pdf", 1) - text, err := p.ExtractText(pdftable.DefaultTextOpts()) + text, err := p.ExtractText(pdfgrab.DefaultTextOpts()) if err != nil { t.Fatalf("ExtractText: %v", err) } @@ -125,7 +125,7 @@ func TestDifferencesFixtureDecoding(t *testing.T) { // the coverage silently collapses again — so assert the breadth, not just // the output. func TestStandard14FixtureCoversEveryLatinFont(t *testing.T) { - doc, err := pdftable.OpenFile("testdata/golden/fonts-standard14.pdf") + doc, err := pdfgrab.OpenFile("testdata/golden/fonts-standard14.pdf") if err != nil { t.Fatalf("OpenFile: %v", err) } diff --git a/geometry.go b/geometry.go index 1c24ef7..2100411 100644 --- a/geometry.go +++ b/geometry.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import "math" @@ -131,7 +131,7 @@ type ViewRect struct { // 150/72, and for PDF.js it is the viewport scale you passed to // getViewport({scale}). // -// Every coordinate pdftable reports — Char, Word, Line, Rect, Table.BBox +// Every coordinate pdfgrab reports — Char, Word, Line, Rect, Table.BBox // and Table.CellsBBox — is already normalised: the MediaBox origin has // been translated to (0,0) and any /Rotate applied, so the space matches // Page.Width() x Page.Height() exactly. That means no per-page fixups diff --git a/geometry_test.go b/geometry_test.go index 6149a1d..b0f4dea 100644 --- a/geometry_test.go +++ b/geometry_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "math" diff --git a/go.mod b/go.mod index 42c8d33..1cf8f08 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/hallelx2/pdftable +module github.com/hallelx2/pdfgrab go 1.25.0 diff --git a/golden_test.go b/golden_test.go index 3e05935..8d45141 100644 --- a/golden_test.go +++ b/golden_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable_test +package pdfgrab_test import ( "encoding/json" @@ -12,13 +12,13 @@ import ( "strings" "testing" - "github.com/hallelx2/pdftable" + "github.com/hallelx2/pdfgrab" ) // Golden-file tests against pdfplumber output. The expected JSON files // in testdata/golden are generated by scripts/gen_golden.py running the // reference pdfplumber library on the same fixture PDFs. We assert -// that pdftable's output matches the algorithm-level behaviour of +// that pdfgrab's output matches the algorithm-level behaviour of // pdfplumber: word count, word text, word order, and word direction // are exact. Horizontal positions now match to 0.01pt — both libraries // resolve standard-14 glyph widths from the same Adobe AFM metrics. @@ -64,7 +64,7 @@ type golden struct { Pages []goldenPage `json:"pages"` } -// TestGoldenAgainstPdfplumber loads each fixture, runs the pdftable +// TestGoldenAgainstPdfplumber loads each fixture, runs the pdfgrab // extraction, and diffs against the pre-generated pdfplumber output. func TestGoldenAgainstPdfplumber(t *testing.T) { dir := filepath.Join("testdata", "golden") @@ -148,12 +148,12 @@ func TestGoldenTablesAgainstPdfplumber(t *testing.T) { } stem := strings.TrimSuffix(name, ".tables.expected.json") t.Run(stem, func(t *testing.T) { - runGoldenTablesCase(t, dir, stem, pdftable.DefaultTableSettings()) + runGoldenTablesCase(t, dir, stem, pdfgrab.DefaultTableSettings()) }) } } -// TestGoldenTablesTextStrategyAgainstPdfplumber asserts pdftable's +// TestGoldenTablesTextStrategyAgainstPdfplumber asserts pdfgrab's // "text" strategy output matches pdfplumber's // find_tables({"text", "text"}) on every .tables-text.expected.json // fixture in testdata/golden. The strategy-specific suffix lets us @@ -165,9 +165,9 @@ func TestGoldenTablesTextStrategyAgainstPdfplumber(t *testing.T) { if err != nil { t.Fatalf("read golden dir: %v", err) } - settings := pdftable.DefaultTableSettings() - settings.VerticalStrategy = pdftable.StrategyText - settings.HorizontalStrategy = pdftable.StrategyText + settings := pdfgrab.DefaultTableSettings() + settings.VerticalStrategy = pdfgrab.StrategyText + settings.HorizontalStrategy = pdfgrab.StrategyText for _, e := range entries { if e.IsDir() { continue @@ -183,12 +183,12 @@ func TestGoldenTablesTextStrategyAgainstPdfplumber(t *testing.T) { } } -func runGoldenTablesCase(t *testing.T, dir, stem string, settings pdftable.TableSettings) { +func runGoldenTablesCase(t *testing.T, dir, stem string, settings pdfgrab.TableSettings) { t.Helper() runGoldenTablesCaseSuffix(t, dir, stem, ".tables.expected.json", settings) } -func runGoldenTablesCaseSuffix(t *testing.T, dir, stem, suffix string, settings pdftable.TableSettings) { +func runGoldenTablesCaseSuffix(t *testing.T, dir, stem, suffix string, settings pdfgrab.TableSettings) { t.Helper() pdfPath := filepath.Join(dir, stem+".pdf") jsonPath := filepath.Join(dir, stem+suffix) @@ -202,7 +202,7 @@ func runGoldenTablesCaseSuffix(t *testing.T, dir, stem, suffix string, settings t.Fatalf("parse %s: %v", jsonPath, err) } - doc, err := pdftable.OpenFile(pdfPath) + doc, err := pdfgrab.OpenFile(pdfPath) if err != nil { t.Fatalf("OpenFile %s: %v", pdfPath, err) } @@ -250,7 +250,7 @@ func runGoldenTablesCaseSuffix(t *testing.T, dir, stem, suffix string, settings // normaliseCellText collapses runs of whitespace into single spaces // and strips leading / trailing whitespace. Used to compare -// pdftable's cell output against pdfplumber's: both sides should +// pdfgrab's cell output against pdfplumber's: both sides should // agree on the token sequence even if the intra-cell line break // convention differs. func normaliseCellText(s string) string { @@ -272,7 +272,7 @@ func runGoldenCase(t *testing.T, dir, stem string) { t.Fatalf("parse %s: %v", jsonPath, err) } - doc, err := pdftable.OpenFile(pdfPath) + doc, err := pdfgrab.OpenFile(pdfPath) if err != nil { t.Fatalf("OpenFile %s: %v", pdfPath, err) } @@ -297,7 +297,7 @@ func runGoldenCase(t *testing.T, dir, stem string) { t.Errorf("page %d height: got %v, want %v", expPage.Number, p.Height(), expPage.Height) } - gotWords, err := p.Words(pdftable.DefaultWordOpts()) + gotWords, err := p.Words(pdfgrab.DefaultWordOpts()) if err != nil { t.Fatalf("Words: %v", err) } @@ -307,7 +307,7 @@ func runGoldenCase(t *testing.T, dir, stem string) { // when split on whitespace. We don't compare byte-equal because // pdfplumber sometimes inserts extra spaces for vertical jitter // that our YTolerance clustering smooths over. - gotText, err := p.ExtractText(pdftable.DefaultTextOpts()) + gotText, err := p.ExtractText(pdfgrab.DefaultTextOpts()) if err != nil { t.Fatalf("ExtractText: %v", err) } @@ -315,7 +315,7 @@ func runGoldenCase(t *testing.T, dir, stem string) { } } -func assertGoldenWords(t *testing.T, page int, got []pdftable.Word, want []goldenWord) { +func assertGoldenWords(t *testing.T, page int, got []pdfgrab.Word, want []goldenWord) { t.Helper() if len(got) != len(want) { t.Errorf("page %d: got %d words, want %d", page, len(got), len(want)) @@ -343,11 +343,11 @@ func assertGoldenWords(t *testing.T, page int, got []pdftable.Word, want []golde // Position tolerances, split by axis because the two axes are in // different states. // - // HORIZONTAL is solved. This was 15pt while pdftable guessed a flat + // HORIZONTAL is solved. This was 15pt while pdfgrab guessed a flat // 500/1000 width for standard-14 fonts shipping no /Widths array; // measured X drift against these fixtures was then 11.99pt max / // 4.79pt mean. With the Adobe Core 14 AFM widths bundled it is - // exactly 0.0000pt — pdftable and pdfplumber agree bit-for-bit. 0.01 + // exactly 0.0000pt — pdfgrab and pdfplumber agree bit-for-bit. 0.01 // matches the page-dimension tolerance above and leaves room for // nothing but float noise. Deliberately tight: at 15pt this // assertion would still pass if the widths regressed all the way diff --git a/internal/layout/lines.go b/internal/layout/lines.go index a688de5..9af0f6a 100644 --- a/internal/layout/lines.go +++ b/internal/layout/lines.go @@ -5,11 +5,11 @@ // table-finding: edges, edge-derivation from Lines/Rects/Curves, and // edge merging (snap + join). // -// The split between this internal package and the public pdftable +// The split between this internal package and the public pdfgrab // package mirrors the pdfplumber split between // pdfplumber/utils/geometry.py (edge maths) and pdfplumber/table.py // (the TableFinder). Keeping the edge maths here lets us evolve the -// representation freely while the public surface in pdftable's +// representation freely while the public surface in pdfgrab's // table.go / finder.go stays stable. // // Coordinate system: PDF user space — origin at bottom-left, Y growing @@ -133,8 +133,8 @@ func (e Edge) normalise() Edge { // LineSegment is a minimal struct describing a drawn straight-line // segment. It exists so this package doesn't have to import the -// public pdftable types — keeps the dependency direction one-way -// (pdftable depends on layout, not the other way round). +// public pdfgrab types — keeps the dependency direction one-way +// (pdfgrab depends on layout, not the other way round). type LineSegment struct { X0, Y0, X1, Y1 float64 Width float64 diff --git a/internal/pdf/afm_widths.go b/internal/pdf/afm_widths.go index 0027999..d8a2efc 100644 --- a/internal/pdf/afm_widths.go +++ b/internal/pdf/afm_widths.go @@ -9,7 +9,7 @@ package pdf // spec (viewers are expected to already know their metrics), so a PDF // that uses plain "Helvetica" with no embedded font program and no // /Widths array is fully spec-compliant -- and until this table existed, -// pdftable had no way to know that font's actual glyph widths, and fell +// pdfgrab had no way to know that font's actual glyph widths, and fell // back to a flat 500/1000 guess for every glyph (see Font.CharWidth). // That flat guess is what produced the up-to-~10pt word-bbox drift // documented in the README's parity notes. diff --git a/internal/pdf/content.go b/internal/pdf/content.go index f63118e..20f8d0b 100644 --- a/internal/pdf/content.go +++ b/internal/pdf/content.go @@ -46,7 +46,7 @@ type Interpreter struct { XObjects map[string]XObject // Sink receives emitted events. The caller installs whatever - // implementation it likes — pdftable's Page uses a struct that + // implementation it likes — pdfgrab's Page uses a struct that // accumulates Chars and paints into separate slices. Sink Sink @@ -62,7 +62,7 @@ type Interpreter struct { } // Sink receives high-level events from the content interpreter. It is -// implemented by the page-builder in the parent package; per pdftable's +// implemented by the page-builder in the parent package; per pdfgrab's // package boundary, this is the contract between the parser and the // public-API layer. type Sink interface { diff --git a/internal/pdf/reader.go b/internal/pdf/reader.go index c20752b..cb3902c 100644 --- a/internal/pdf/reader.go +++ b/internal/pdf/reader.go @@ -38,7 +38,7 @@ func NewReader(data []byte) (*Reader, error) { conf.ValidationMode = model.ValidationRelaxed ctx, err := api.ReadContext(bytes.NewReader(data), conf) if err != nil { - return nil, fmt.Errorf("pdftable: read: %w", err) + return nil, fmt.Errorf("pdfgrab: read: %w", err) } if ctx.Encrypt != nil && ctx.E != nil { // pdfcpu auto-decrypts with the configured password (empty by @@ -51,7 +51,7 @@ func NewReader(data []byte) (*Reader, error) { // (we surface ErrEncrypted). } if err := ctx.EnsurePageCount(); err != nil { - return nil, fmt.Errorf("pdftable: page count: %w", err) + return nil, fmt.Errorf("pdfgrab: page count: %w", err) } return &Reader{Ctx: ctx}, nil } diff --git a/internal/pdf/state.go b/internal/pdf/state.go index 1ff6dd7..75bfd5b 100644 --- a/internal/pdf/state.go +++ b/internal/pdf/state.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -// Package pdf is the internal content-stream interpreter for pdftable. +// Package pdf is the internal content-stream interpreter for pdfgrab. // // It is intentionally NOT a public package: the data model here is the // raw output of walking a PDF content stream (glyph events, path diff --git a/merge_split_test.go b/merge_split_test.go index cce927f..098ae94 100644 --- a/merge_split_test.go +++ b/merge_split_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "reflect" diff --git a/page.go b/page.go index 7974fb9..d3f6df2 100644 --- a/page.go +++ b/page.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "fmt" @@ -9,8 +9,8 @@ import ( "math" "strings" - "github.com/hallelx2/pdftable/internal/layout" - "github.com/hallelx2/pdftable/internal/pdf" + "github.com/hallelx2/pdfgrab/internal/layout" + "github.com/hallelx2/pdfgrab/internal/pdf" ) // Page is one page of a PDF document. The interface (not a struct) is @@ -619,7 +619,7 @@ func (p *page) FindTables(settings TableSettings) ([]TableFinder, error) { } } if nV > s.MaxEdgesPerAxis || nH > s.MaxEdgesPerAxis { - log.Printf("pdftable: skipping table finding on page %d: %d vertical / %d horizontal edges exceed MaxEdgesPerAxis=%d (page is not a real table)", + log.Printf("pdfgrab: skipping table finding on page %d: %d vertical / %d horizontal edges exceed MaxEdgesPerAxis=%d (page is not a real table)", p.number, nV, nH, s.MaxEdgesPerAxis) return nil, nil } @@ -630,7 +630,7 @@ func (p *page) FindTables(settings TableSettings) ([]TableFinder, error) { // Second safety cap at the intersection stage — bounds the work // even if some future input defeats the grid-indexed cell finder. if s.MaxIntersections > 0 && len(intersections) > s.MaxIntersections { - log.Printf("pdftable: skipping table finding on page %d: %d intersections exceed MaxIntersections=%d", + log.Printf("pdfgrab: skipping table finding on page %d: %d intersections exceed MaxIntersections=%d", p.number, len(intersections), s.MaxIntersections) return nil, nil } diff --git a/page_test.go b/page_test.go index fa98a56..7a8ea29 100644 --- a/page_test.go +++ b/page_test.go @@ -1,15 +1,15 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable_test +package pdfgrab_test import ( "math" "strings" "testing" - "github.com/hallelx2/pdftable" - "github.com/hallelx2/pdftable/testdata" + "github.com/hallelx2/pdfgrab" + "github.com/hallelx2/pdfgrab/testdata" ) // TestCharsHelloWorld walks the hand-crafted "Hello, world!" page @@ -24,7 +24,7 @@ import ( // non-space chars). // 3. Y baseline is roughly where the content stream put it (720). func TestCharsHelloWorld(t *testing.T) { - doc, err := pdftable.OpenBytes(testdata.Hello()) + doc, err := pdfgrab.OpenBytes(testdata.Hello()) if err != nil { t.Fatalf("OpenBytes: %v", err) } @@ -89,7 +89,7 @@ func TestCharsHelloWorld(t *testing.T) { // - one horizontal line from (100, 580) to (150, 580) // - one vertical line from (100, 500) to (100, 550) func TestRectsRulings(t *testing.T) { - doc, err := pdftable.OpenBytes(testdata.Rules()) + doc, err := pdfgrab.OpenBytes(testdata.Rules()) if err != nil { t.Fatalf("OpenBytes: %v", err) } @@ -151,7 +151,7 @@ func TestRectsRulings(t *testing.T) { // TestObjectsAggregator confirms Objects() bundles all four primitive // collections in a single call and matches the per-type accessors. func TestObjectsAggregator(t *testing.T) { - doc, err := pdftable.OpenBytes(testdata.Rules()) + doc, err := pdfgrab.OpenBytes(testdata.Rules()) if err != nil { t.Fatalf("OpenBytes: %v", err) } @@ -179,7 +179,7 @@ func TestObjectsAggregator(t *testing.T) { // minimal real PDF without crashing or producing nonsense", which is // the smoke-test floor for the v0.0.1 release. func TestRealWorldSimplePDF(t *testing.T) { - doc, err := pdftable.OpenFile("testdata/simple1.pdf") + doc, err := pdfgrab.OpenFile("testdata/simple1.pdf") if err != nil { t.Fatalf("OpenFile: %v", err) } diff --git a/pdf.go b/pdf.go index e1ae958..5783118 100644 --- a/pdf.go +++ b/pdf.go @@ -1,13 +1,13 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "fmt" "iter" - "github.com/hallelx2/pdftable/internal/pdf" + "github.com/hallelx2/pdfgrab/internal/pdf" ) // Document represents one open PDF file. The interface (not a struct) diff --git a/pdftable.go b/pdftable.go index dc5beb2..9d8f9d5 100644 --- a/pdftable.go +++ b/pdftable.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -// Package pdftable is a Go-native port of Python's pdfplumber. It +// Package pdfgrab is a Go-native port of Python's pdfplumber. It // reads a PDF document, walks the content streams, and surfaces the // positioned primitives (characters, lines, rectangles, curves) that // higher-level layout algorithms — text extraction, word grouping, @@ -10,18 +10,18 @@ // The library is structured in layers that mirror the pdfplumber + // pdfminer.six split: // -// - This package (pdftable) is the public API. It is small, stable, +// - This package (pdfgrab) is the public API. It is small, stable, // and contains no PDF parsing logic of its own — it just exposes // Document, Page, Char, Line, Rect, Curve and constructs them // from the internal package. -// - github.com/hallelx2/pdftable/internal/pdf is the content-stream +// - github.com/hallelx2/pdfgrab/internal/pdf is the content-stream // interpreter. It is intentionally not public: the data shapes // there will evolve as we add more PDF features, and we don't // want callers depending on them. // // Typical usage: // -// doc, err := pdftable.OpenFile("report.pdf") +// doc, err := pdfgrab.OpenFile("report.pdf") // if err != nil { return err } // defer doc.Close() // @@ -36,7 +36,7 @@ // the README for the roadmap. The Page interface is additive across // releases; v0.0.1 callers using only Chars/Lines/Rects/Curves // continue to compile against v0.1.0 without changes. -package pdftable +package pdfgrab import ( "bytes" @@ -45,7 +45,7 @@ import ( "io" "os" - "github.com/hallelx2/pdftable/internal/pdf" + "github.com/hallelx2/pdfgrab/internal/pdf" ) // Open reads an entire io.Reader into memory and parses it as a PDF. @@ -96,7 +96,7 @@ func OpenBytes(b []byte) (Document, error) { func OpenFile(path string) (Document, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("pdftable: open %s: %w", path, err) + return nil, fmt.Errorf("pdfgrab: open %s: %w", path, err) } return OpenBytes(data) } @@ -156,7 +156,7 @@ var ( _ Page = (*page)(nil) ) -// errIs is a tiny re-export so callers that vendor pdftable into a +// errIs is a tiny re-export so callers that vendor pdfgrab into a // monorepo don't have to also import "errors" for the common case of // matching one of our sentinels. func errIs(err, target error) bool { return errors.Is(err, target) } diff --git a/pdftable_test.go b/pdftable_test.go index 75afc83..472571e 100644 --- a/pdftable_test.go +++ b/pdftable_test.go @@ -1,15 +1,15 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable_test +package pdfgrab_test import ( "bytes" "errors" "testing" - "github.com/hallelx2/pdftable" - "github.com/hallelx2/pdftable/testdata" + "github.com/hallelx2/pdfgrab" + "github.com/hallelx2/pdfgrab/testdata" ) // TestOpenAndCount checks the Open* surface plus NumPages on the @@ -17,7 +17,7 @@ import ( // memory by testdata.Hello() to keep the repo binary-free. func TestOpenAndCount(t *testing.T) { data := testdata.Hello() - doc, err := pdftable.OpenBytes(data) + doc, err := pdfgrab.OpenBytes(data) if err != nil { t.Fatalf("OpenBytes: %v", err) } @@ -41,7 +41,7 @@ func TestOpenAndCount(t *testing.T) { // *bytes.Reader so the result must match OpenBytes exactly. func TestOpenReader(t *testing.T) { data := testdata.Hello() - doc, err := pdftable.Open(bytes.NewReader(data)) + doc, err := pdfgrab.Open(bytes.NewReader(data)) if err != nil { t.Fatalf("Open: %v", err) } @@ -53,7 +53,7 @@ func TestOpenReader(t *testing.T) { // TestPagesIterator exercises the iter.Seq2 range iterator. func TestPagesIterator(t *testing.T) { - doc, err := pdftable.OpenBytes(testdata.Hello()) + doc, err := pdfgrab.OpenBytes(testdata.Hello()) if err != nil { t.Fatalf("OpenBytes: %v", err) } @@ -78,7 +78,7 @@ func TestPagesIterator(t *testing.T) { // TestPageOutOfRange checks that the sentinel is returned correctly // for both too-low and too-high page indices. func TestPageOutOfRange(t *testing.T) { - doc, err := pdftable.OpenBytes(testdata.Hello()) + doc, err := pdfgrab.OpenBytes(testdata.Hello()) if err != nil { t.Fatalf("OpenBytes: %v", err) } @@ -87,7 +87,7 @@ func TestPageOutOfRange(t *testing.T) { cases := []int{0, -1, 2, 100} for _, n := range cases { _, err := doc.Page(n) - if !errors.Is(err, pdftable.ErrPageOutOfRange) { + if !errors.Is(err, pdfgrab.ErrPageOutOfRange) { t.Errorf("Page(%d) err = %v, want ErrPageOutOfRange", n, err) } } @@ -104,8 +104,8 @@ func TestInvalidPDF(t *testing.T) { []byte("plain text"), } for _, c := range cases { - _, err := pdftable.OpenBytes(c) - if !errors.Is(err, pdftable.ErrInvalidPDF) { + _, err := pdfgrab.OpenBytes(c) + if !errors.Is(err, pdfgrab.ErrInvalidPDF) { t.Errorf("OpenBytes(%q): err = %v, want ErrInvalidPDF", c, err) } } diff --git a/scripts/capture_pdfplumber_text_golden.py b/scripts/capture_pdfplumber_text_golden.py index 02e402d..524dd4b 100644 --- a/scripts/capture_pdfplumber_text_golden.py +++ b/scripts/capture_pdfplumber_text_golden.py @@ -1,4 +1,4 @@ -"""Generate text-strategy golden files for pdftable's parity tests. +"""Generate text-strategy golden files for pdfgrab's parity tests. Run from the repo root after copying any new borderless / text-strategy fixture PDFs into testdata/golden/: diff --git a/scripts/gen_font_fixtures.py b/scripts/gen_font_fixtures.py index ab9b49d..7856a6c 100644 --- a/scripts/gen_font_fixtures.py +++ b/scripts/gen_font_fixtures.py @@ -1,4 +1,4 @@ -"""Generate font-coverage fixture PDFs for pdftable's tests. +"""Generate font-coverage fixture PDFs for pdfgrab's tests. python scripts/gen_font_fixtures.py python scripts/gen_golden.py # regenerate pdfplumber goldens diff --git a/scripts/gen_golden.py b/scripts/gen_golden.py index 892b379..565c0b6 100644 --- a/scripts/gen_golden.py +++ b/scripts/gen_golden.py @@ -1,4 +1,4 @@ -"""Generate golden-file expected outputs for pdftable's parity tests. +"""Generate golden-file expected outputs for pdfgrab's parity tests. Run from the repo root after copying the fixture PDFs into testdata/golden/: @@ -14,9 +14,9 @@ `lines` strategy (v0.2.0 tests). Word goldens use image-space "top" / "bottom" translated into -PDF-user-space y0 / y1 so they match pdftable.Word fields directly. +PDF-user-space y0 / y1 so they match pdfgrab.Word fields directly. Table goldens are the raw [[[str]]] output of pdfplumber's -Table.extract() — pdftable's parity test normalises whitespace before +Table.extract() — pdfgrab's parity test normalises whitespace before comparing, so intra-cell line breaks ("A\\nB") match space-separated output ("A B") and vice versa. diff --git a/scripts/gen_table_fixture.go b/scripts/gen_table_fixture.go index 1dabb3b..743bdfb 100644 --- a/scripts/gen_table_fixture.go +++ b/scripts/gen_table_fixture.go @@ -28,7 +28,7 @@ import ( "os" "path/filepath" - "github.com/hallelx2/pdftable/testdata" + "github.com/hallelx2/pdfgrab/testdata" ) func main() { diff --git a/spacing_regression_test.go b/spacing_regression_test.go index 630ec57..f19609d 100644 --- a/spacing_regression_test.go +++ b/spacing_regression_test.go @@ -1,4 +1,4 @@ -package pdftable +package pdfgrab import ( "os" diff --git a/strategy_auto_test.go b/strategy_auto_test.go index 42b6c60..601db7f 100644 --- a/strategy_auto_test.go +++ b/strategy_auto_test.go @@ -1,12 +1,12 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "testing" - "github.com/hallelx2/pdftable/internal/layout" + "github.com/hallelx2/pdfgrab/internal/layout" ) func vEdge(x, y0, y1 float64) layout.Edge { diff --git a/table.go b/table.go index 8c1ee2d..81e9af1 100644 --- a/table.go +++ b/table.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab // This file defines the public types of the table-finding pipeline: // TableSettings (with pdfplumber-matching defaults), Table (the @@ -67,7 +67,7 @@ const ( // 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 + // that accounted for every document where pdfgrab 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. // @@ -227,8 +227,8 @@ type TableSettings struct { // DefaultTableSettings returns settings with the pdfplumber default // values pre-populated. The intended pattern is: // -// settings := pdftable.DefaultTableSettings() -// settings.VerticalStrategy = pdftable.StrategyLinesStrict +// settings := pdfgrab.DefaultTableSettings() +// settings.VerticalStrategy = pdfgrab.StrategyLinesStrict // tables, err := page.ExtractTables(settings) // // pdfplumber's defaults (table.py lines 9-12, 486-503): diff --git a/table_test.go b/table_test.go index 73dcf70..2dd7598 100644 --- a/table_test.go +++ b/table_test.go @@ -1,10 +1,10 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab -// table_test.go is intentionally in the pdftable package (not -// pdftable_test) so it can reach the unexported algorithm functions: +// table_test.go is intentionally in the pdfgrab package (not +// pdfgrab_test) so it can reach the unexported algorithm functions: // edgesToIntersections, intersectionsToCells, cellsToTables, // assembleTableBox, runTableFinder. The public-API integration test // (TestExtractTables_RuledFixture) lives at the end and uses the @@ -14,8 +14,8 @@ import ( "strings" "testing" - "github.com/hallelx2/pdftable/internal/layout" - "github.com/hallelx2/pdftable/testdata" + "github.com/hallelx2/pdfgrab/internal/layout" + "github.com/hallelx2/pdfgrab/testdata" ) // makeH builds a horizontal edge at Y = y from X0 = x0 to X1 = x1. @@ -340,7 +340,7 @@ func TestApplyDefaults_FillsZeroFields(t *testing.T) { // This test uses the public API only — the unit tests above cover // the unexported algorithm functions. func TestExtractTables_RuledFixture(t *testing.T) { - // Import path is package-internal here (we're in the pdftable + // Import path is package-internal here (we're in the pdfgrab // package, not _test), so OpenBytes is unqualified. doc, err := OpenBytes(testdata.TableRuled()) if err != nil { @@ -469,7 +469,7 @@ func TestExtractTables_NegativeCapDisables(t *testing.T) { } // TestApplyDefaults_FillsSafetyCaps asserts the new safety-cap fields -// get their pdftable defaults when left zero, matching the +// get their pdfgrab defaults when left zero, matching the // zero-value-gets-defaults convention of the other TableSettings // fields. func TestApplyDefaults_FillsSafetyCaps(t *testing.T) { diff --git a/testdata/fixtures.go b/testdata/fixtures.go index 7bf7faa..bd018aa 100644 --- a/testdata/fixtures.go +++ b/testdata/fixtures.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -// Package testdata builds the test PDFs used by pdftable's tests. +// Package testdata builds the test PDFs used by pdfgrab's tests. // // We hand-craft the PDFs as byte slices rather than checking in // binaries because: diff --git a/text.go b/text.go index c841977..cb141d2 100644 --- a/text.go +++ b/text.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "math" @@ -174,7 +174,7 @@ type WordOpts struct { // DefaultWordOpts returns a WordOpts populated with pdfplumber-matching // defaults. Use this and override the fields you care about: // -// opts := pdftable.DefaultWordOpts() +// opts := pdfgrab.DefaultWordOpts() // opts.XTolerance = 1.5 // words, _ := page.Words(opts) func DefaultWordOpts() WordOpts { diff --git a/text_test.go b/text_test.go index 375e54c..549e9cc 100644 --- a/text_test.go +++ b/text_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable +package pdfgrab import ( "math" @@ -415,14 +415,14 @@ func TestExpandLigatures(t *testing.T) { func openHelloWorldDoc() (Document, error) { // We import testdata indirectly via Open: that means this test // can't live in the _test package because testdata is in a sub- - // directory and we're already in the pdftable package. Build a + // directory and we're already in the pdfgrab package. Build a // dependency-free fixture inline instead — same structure as // testdata.Hello(). return OpenBytes(helloBytes()) } // helloBytes returns the same PDF as testdata.Hello(), inlined so this -// _test.go file inside the pdftable package doesn't pull in a +// _test.go file inside the pdfgrab package doesn't pull in a // dependency on testdata. (testdata/fixtures.go is in a sub-package and // importing it here would cause a cycle.) func helloBytes() []byte { diff --git a/viewport_test.go b/viewport_test.go index bbefe1e..5807502 100644 --- a/viewport_test.go +++ b/viewport_test.go @@ -1,13 +1,13 @@ // Copyright (c) 2026 Halleluyah Oludele // Licensed under the MIT License. -package pdftable_test +package pdfgrab_test import ( "math" "testing" - "github.com/hallelx2/pdftable" + "github.com/hallelx2/pdfgrab" ) func closeTo(t *testing.T, label string, got, want float64) { @@ -27,7 +27,7 @@ func TestViewportFlipsYAxis(t *testing.T) { // A box sitting at the very TOP of the page (high Y in PDF space) // must come back with a near-ZERO Top in viewer space. - top := pdftable.BBox{X0: 100, Y0: 782, X1: 200, Y1: 792} + top := pdfgrab.BBox{X0: 100, Y0: 782, X1: 200, Y1: 792} r := top.Viewport(pageH, 1) closeTo(t, "top box Top", r.Top, 0) closeTo(t, "top box Left", r.Left, 100) @@ -35,7 +35,7 @@ func TestViewportFlipsYAxis(t *testing.T) { closeTo(t, "top box Height", r.Height, 10) // A box at the BOTTOM (low Y) must land at the far side. - bottom := pdftable.BBox{X0: 0, Y0: 0, X1: 10, Y1: 10} + bottom := pdfgrab.BBox{X0: 0, Y0: 0, X1: 10, Y1: 10} rb := bottom.Viewport(pageH, 1) closeTo(t, "bottom box Top", rb.Top, 782) @@ -48,7 +48,7 @@ func TestViewportFlipsYAxis(t *testing.T) { // other half of what a frontend needs. func TestViewportScale(t *testing.T) { const pageH = 792.0 - b := pdftable.BBox{X0: 56.7, Y0: 559.7, X1: 537.9, Y1: 568.4} + b := pdfgrab.BBox{X0: 56.7, Y0: 559.7, X1: 537.9, Y1: 568.4} // 150 DPI raster: 150/72 pixels per point. These are the real // coordinates of the "Less: Accumulated depreciation" row on 3M's @@ -69,7 +69,7 @@ func TestViewportScale(t *testing.T) { // which is what a resizable viewer should store. func TestNormalizedIsResolutionIndependent(t *testing.T) { const pageW, pageH = 612.0, 792.0 - b := pdftable.BBox{X0: 306, Y0: 396, X1: 612, Y1: 792} // exact top-right quadrant + b := pdfgrab.BBox{X0: 306, Y0: 396, X1: 612, Y1: 792} // exact top-right quadrant n := b.Normalized(pageW, pageH) closeTo(t, "Left", n.Left, 0.5) @@ -87,7 +87,7 @@ func TestNormalizedIsResolutionIndependent(t *testing.T) { } // Degenerate pages must not emit NaN into a JSON payload. - if got := b.Normalized(0, 0); got != (pdftable.ViewRect{}) { + if got := b.Normalized(0, 0); got != (pdfgrab.ViewRect{}) { t.Errorf("Normalized on a zero-sized page = %+v, want zero ViewRect", got) } } @@ -96,7 +96,7 @@ func TestNormalizedIsResolutionIndependent(t *testing.T) { // bbox from a real extraction and confirm the viewer rectangle lands // inside the page, right way up. func TestViewportOnRealCitation(t *testing.T) { - doc, err := pdftable.OpenFile("testdata/golden/simple1.pdf") + doc, err := pdfgrab.OpenFile("testdata/golden/simple1.pdf") if err != nil { t.Skipf("fixture unavailable: %v", err) } @@ -105,13 +105,13 @@ func TestViewportOnRealCitation(t *testing.T) { if err != nil { t.Fatalf("Page: %v", err) } - words, err := p.Words(pdftable.DefaultWordOpts()) + words, err := p.Words(pdfgrab.DefaultWordOpts()) if err != nil || len(words) == 0 { t.Fatalf("Words: %v (n=%d)", err, len(words)) } pw, ph := p.Width(), p.Height() for _, w := range words { - b := pdftable.BBox{X0: w.X0, Y0: w.Y0, X1: w.X1, Y1: w.Y1} + b := pdfgrab.BBox{X0: w.X0, Y0: w.Y0, X1: w.X1, Y1: w.Y1} r := b.Viewport(ph, 1) if r.Top < 0 || r.Top > ph { t.Errorf("word %q: Top=%v outside page height %v", w.Text, r.Top, ph)