inkgrid 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. inkgrid-0.1.0/LICENSE +21 -0
  2. inkgrid-0.1.0/PKG-INFO +404 -0
  3. inkgrid-0.1.0/README.md +374 -0
  4. inkgrid-0.1.0/pyproject.toml +247 -0
  5. inkgrid-0.1.0/pyproject.toml.orig +155 -0
  6. inkgrid-0.1.0/src/inkgrid/__init__.py +100 -0
  7. inkgrid-0.1.0/src/inkgrid/__main__.py +5 -0
  8. inkgrid-0.1.0/src/inkgrid/api.py +167 -0
  9. inkgrid-0.1.0/src/inkgrid/cli.py +225 -0
  10. inkgrid-0.1.0/src/inkgrid/core/__init__.py +1 -0
  11. inkgrid-0.1.0/src/inkgrid/core/assemble.py +320 -0
  12. inkgrid-0.1.0/src/inkgrid/core/calls.py +184 -0
  13. inkgrid-0.1.0/src/inkgrid/core/furniture.py +292 -0
  14. inkgrid-0.1.0/src/inkgrid/core/glossary.py +305 -0
  15. inkgrid-0.1.0/src/inkgrid/core/joins.py +191 -0
  16. inkgrid-0.1.0/src/inkgrid/core/layout.py +211 -0
  17. inkgrid-0.1.0/src/inkgrid/core/lexicon.py +301 -0
  18. inkgrid-0.1.0/src/inkgrid/core/lines.py +159 -0
  19. inkgrid-0.1.0/src/inkgrid/core/notes.py +132 -0
  20. inkgrid-0.1.0/src/inkgrid/core/order.py +52 -0
  21. inkgrid-0.1.0/src/inkgrid/core/pipeline.py +135 -0
  22. inkgrid-0.1.0/src/inkgrid/core/prose.py +180 -0
  23. inkgrid-0.1.0/src/inkgrid/core/tables/__init__.py +1 -0
  24. inkgrid-0.1.0/src/inkgrid/core/tables/chart.py +251 -0
  25. inkgrid-0.1.0/src/inkgrid/core/tables/corridor.py +942 -0
  26. inkgrid-0.1.0/src/inkgrid/core/tables/grid.py +84 -0
  27. inkgrid-0.1.0/src/inkgrid/core/tables/lattice.py +294 -0
  28. inkgrid-0.1.0/src/inkgrid/core/tables/pages.py +19 -0
  29. inkgrid-0.1.0/src/inkgrid/core/tables/proto.py +122 -0
  30. inkgrid-0.1.0/src/inkgrid/core/tables/shape.py +65 -0
  31. inkgrid-0.1.0/src/inkgrid/core/text.py +34 -0
  32. inkgrid-0.1.0/src/inkgrid/core/view.py +49 -0
  33. inkgrid-0.1.0/src/inkgrid/errors.py +33 -0
  34. inkgrid-0.1.0/src/inkgrid/model/__init__.py +64 -0
  35. inkgrid-0.1.0/src/inkgrid/model/base.py +27 -0
  36. inkgrid-0.1.0/src/inkgrid/model/canonical.py +34 -0
  37. inkgrid-0.1.0/src/inkgrid/model/config.py +112 -0
  38. inkgrid-0.1.0/src/inkgrid/model/document.py +458 -0
  39. inkgrid-0.1.0/src/inkgrid/model/export.py +165 -0
  40. inkgrid-0.1.0/src/inkgrid/model/findings.py +106 -0
  41. inkgrid-0.1.0/src/inkgrid/model/geometry.py +211 -0
  42. inkgrid-0.1.0/src/inkgrid/model/invariants.py +297 -0
  43. inkgrid-0.1.0/src/inkgrid/model/lattice.py +73 -0
  44. inkgrid-0.1.0/src/inkgrid/model/page.py +194 -0
  45. inkgrid-0.1.0/src/inkgrid/model/verification.py +211 -0
  46. inkgrid-0.1.0/src/inkgrid/py.typed +0 -0
  47. inkgrid-0.1.0/src/inkgrid/read/__init__.py +1 -0
  48. inkgrid-0.1.0/src/inkgrid/read/camelot_reader.py +239 -0
  49. inkgrid-0.1.0/src/inkgrid/read/glyphs.py +296 -0
  50. inkgrid-0.1.0/src/inkgrid/read/page_findings.py +100 -0
  51. inkgrid-0.1.0/src/inkgrid/read/pymupdf_reader.py +508 -0
  52. inkgrid-0.1.0/src/inkgrid/read/raw.py +84 -0
  53. inkgrid-0.1.0/src/inkgrid/read/rules.py +135 -0
  54. inkgrid-0.1.0/src/inkgrid/read/source.py +67 -0
  55. inkgrid-0.1.0/src/inkgrid/read/words.py +290 -0
  56. inkgrid-0.1.0/src/inkgrid/render/__init__.py +1 -0
  57. inkgrid-0.1.0/src/inkgrid/render/inspector.py +247 -0
  58. inkgrid-0.1.0/src/inkgrid/verify/__init__.py +5 -0
  59. inkgrid-0.1.0/src/inkgrid/verify/checks.py +416 -0
  60. inkgrid-0.1.0/src/inkgrid/verify/ink.py +164 -0
  61. inkgrid-0.1.0/src/inkgrid/verify/ownership.py +410 -0
  62. inkgrid-0.1.0/src/inkgrid/verify/pdfium_reader.py +256 -0
  63. inkgrid-0.1.0/src/inkgrid/verify/report.py +197 -0
  64. inkgrid-0.1.0/src/inkgrid/verify/rules.py +108 -0
inkgrid-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdul-Muizz Anwar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
inkgrid-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,404 @@
1
+ Metadata-Version: 2.4
2
+ Name: inkgrid
3
+ Version: 0.1.0
4
+ Summary: Exact tables and text from born-digital PDFs, read from the text layer and drawn rules, and verified by a second PDF engine.
5
+ Keywords: pdf,tables,text-extraction,pymupdf,camelot,document-parsing
6
+ Author: Abdul-Muizz Anwar
7
+ Author-email: Abdul-Muizz Anwar <abdulmuizz1310@outlook.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Text Processing
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: camelot-py>=2.0.0,<3
20
+ Requires-Dist: opencv-python-headless>=4.14.0.94,!=5.0.0.93
21
+ Requires-Dist: pydantic>=2.11,<3
22
+ Requires-Dist: pymupdf>=1.28.2,<2
23
+ Requires-Dist: pypdfium2>=5.13.0,<6
24
+ Requires-Python: >=3.12
25
+ Project-URL: Homepage, https://github.com/Abdul-Muizz1310/inkgrid
26
+ Project-URL: Source, https://github.com/Abdul-Muizz1310/inkgrid
27
+ Project-URL: Issues, https://github.com/Abdul-Muizz1310/inkgrid/issues
28
+ Project-URL: Changelog, https://github.com/Abdul-Muizz1310/inkgrid/blob/main/CHANGELOG.md
29
+ Description-Content-Type: text/markdown
30
+
31
+ <h1 align="center">inkgrid</h1>
32
+
33
+ <p align="center">Exact tables and text from born-digital PDFs, read from the text layer and the drawn rules, and checked by a second PDF engine.</p>
34
+
35
+ <p align="center">
36
+ <a href="https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/WHY.md">Why</a> •
37
+ <a href="https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/docs/ARCHITECTURE.md">Architecture</a> •
38
+ <a href="https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/docs/DEMO.md">Demo Script</a> •
39
+ <a href="https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/docs/specs/00-design.md">Design</a>
40
+ </p>
41
+
42
+ <p align="center">
43
+ <a href="https://github.com/Abdul-Muizz1310/inkgrid/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/Abdul-Muizz1310/inkgrid/ci.yml?branch=main&label=ci"></a>
44
+ <a href="https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
45
+ </p>
46
+
47
+ > **Status: alpha, 0.1.0 (milestones M0 to M6 complete).**
48
+ > Today inkgrid reads a PDF into a validated `Document`: every word in exactly one block, in reading
49
+ > order, with running headers and footers set apart, tables, ruled or not, as explicit cell grids,
50
+ > glossaries as definitions, and footnote calls linked to their notes. Tables and sentences that run
51
+ > onto the next page are joined. `inkgrid.verify` then grades the document against its PDF with a
52
+ > second engine, PDFium. The roadmap is in
53
+ > [`docs/specs/00-design.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/docs/specs/00-design.md) § 14.
54
+
55
+ ## What it does
56
+
57
+ inkgrid reads born-digital PDFs, the kind produced by a word processor or a report generator, through
58
+ their text layer and their vector drawings. It never recognizes characters from pixels, so it never
59
+ misreads one.
60
+
61
+ When complete, it returns every word of a document in exactly one block (heading, paragraph, list
62
+ item, footnote, definition, table, or page furniture), in reading order. Tables come back as explicit
63
+ cell grids, with merged cells as spans and headers carried across page breaks, marked as carried.
64
+ Links record the relationships the page prints: footnote calls to their notes, and tables that
65
+ continue onto the next page.
66
+
67
+ Shipped so far (M0 to M4):
68
+
69
+ - **Words rebuilt from characters.** A superscript marker printed tight against a value stays its own
70
+ word (`$0.40` and `2`, never `$0.402`). A font change in the middle of a word keeps it one word.
71
+ - **Drawn rules** from the page's vector paths, including cell outlines drawn as rectangles.
72
+ - **Nothing degraded silently.** Each of these comes back as a typed finding:
73
+ - image-only pages, and pages MuPDF cannot load or decode;
74
+ - text a clip path or the CropBox hides;
75
+ - glyphs with no Unicode mapping, and Type 3 fonts, whose glyphs are pictures;
76
+ - text in the layer that the page never shows: render modes 3 and 7, or fully transparent text.
77
+ That is the shape of an OCR layer, or of hidden prompt injection.
78
+ - **Reading order across columns.** Two prose columns read left column, then right. A gutter alone
79
+ does not make columns, because the narrowest table columns sit as close as a label and its text:
80
+ columns need a gutter that persists over several lines and prose on both sides. Tables and
81
+ hanging-indent lists keep row order.
82
+ - **Running headers, footers, and page numbers** found by recurrence across pages and set apart as
83
+ furniture, line by line. A line inside a ruled table is never furniture, and furniture runs from
84
+ the page's edge, so a table's spanning header or stub banner in the page band stays in its table.
85
+ - **Typed blocks:** headings with their section numbers and levels, paragraphs, list items, and
86
+ footnotes, with every line-end hyphen join recorded so it can be undone.
87
+ - **Ruled tables as cell grids.** Camelot's lattice parser reads the pages whose rules run both ways,
88
+ through its cell edge flags, never its DataFrame. A merged cell is one cell with a span, so a value
89
+ is never copied into the positions it covers. Leading value-free rows become header rows, and
90
+ full-width rows become banner rows. A box around a paragraph, a furniture label, or a frame ruled
91
+ around columns of running text is not a table. Nor is a grid over a table already read (a page
92
+ panel, an open frame), and a page border around a table reads as the table alone. The page's own
93
+ drawn rules, thin grey fills included, split a cell they divide.
94
+ Tables export as GFM Markdown, HTML with `colspan`/`rowspan`, and dense rows that flag every
95
+ repeated value.
96
+ - **Unruled tables from whitespace.** Tables set without column rules (SIX, LSE, Euronext) come from
97
+ the whitespace between the value rows' cells, which stays put however a column is aligned. Every row
98
+ votes on where each boundary sits, wrapped lines join their row, a drawn rule ends a row or a cell,
99
+ and two values never share a cell: rows that could only be read by fusing two values stay text,
100
+ with a `table_left_as_text` warning.
101
+ - **Charts are not tables.** Bars whose lengths are in one proportion to the numbers they carry, or a
102
+ numeric axis with a tick at every label, keep a chart's labels as text (`chart_left_as_text`),
103
+ in either table reader. A diagonal watermark is no cell's.
104
+ - **Glossaries and note lists.** Inside a definitions section, an entry that opens with a term becomes
105
+ a definition block with its `term` and `body`: a quoted term (`“ABBO” means …`), a bold one
106
+ (`Available for Distribution:`), or one set in a column of its own (SIX's glossary). Outside such a
107
+ section only a quoted term with a defining verb counts. A two-column grid of note labels beside prose
108
+ becomes footnotes (`X2 | Connection rebate: …`, where a table prints `EMDI^X2`), and inside a
109
+ section, or under its own `Legend` banner, a grid of terms beside prose becomes definitions.
110
+ - **Footnote calls linked to their notes.** Notes are found in every form the measured documents print
111
+ them: small type opening with a number, a number or mark opening its note at the text's own size
112
+ (`^ Contra to …`, `2 Each month …`), a raised number glued to its first word (`1A surcharge`), and
113
+ ruled tables titled `Footnotes`. A note must be called somewhere in the document, so numbered
114
+ clauses are never read as notes. Calls come as superscripts, as parenthesised numbers at body size
115
+ (`Customer (2)(8)(9)`), and as named references (`see footnote 27`). Each call ends resolved to its
116
+ note, unresolved (with a `call_unresolved` warning), or rejected by a named drafting convention:
117
+ `five (5)`, `Section 202(a)(11)`, `except (1)`, and `; (2) if …` are never calls.
118
+ - **Tables and sentences across pages.** A table that runs onto the next page without its header
119
+ carries the header as cells that own no words, linked to the part it continues. A reprinted header
120
+ links the parts without carrying. A sentence broken by the page joins into one paragraph with a
121
+ region on each page.
122
+ - **Markdown and an HTML inspector.** The inspector draws every block over its rendered page, for
123
+ looking at a reading rather than trusting it, and a verification report's defects over the page.
124
+ - **An independent verifier.** `inkgrid verify` re-reads the PDF with PDFium and grades the document
125
+ character by character: nothing lost, invented, or doubled; every cell holding the ink inside it;
126
+ no drawn rule dividing a cell; every value bound to one cell. It exits 1 on any defect. On the 42
127
+ measured fee schedules it accounts for all 1,817,075 characters and reports 8 cells, among them two
128
+ values from two columns read as one word, and a note mark in the column after its word. On
129
+ ICDAR-2013 and olmOCR-bench's table pages it names the ways the two engines read a page
130
+ differently (ligatures, unmappable glyphs, overprinted titles), so what it still reports there is
131
+ inkgrid's own errors: [`docs/specs/11-verify-on-public-corpora.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/docs/specs/11-verify-on-public-corpora.md) § 5 lists them.
132
+ - **The full output contract,** `inkgrid.document/1`, with its invariants enforced: every word owned
133
+ exactly once, cells that tile their grid exactly, and block text spelled from its own words in
134
+ their order.
135
+
136
+ ### Known limitations
137
+
138
+ - **Text is judged hidden from the text layer alone.** Text covered by an opaque shape or image,
139
+ white text on a white page, and text too small to read all read as visible. The verifier reads the
140
+ text layer too, so it does not catch them either.
141
+ - **Errors the verifier finds on public corpora.** On the 31 ICDAR-2013 and olmOCR-bench documents
142
+ where the verifier found inkgrid's own errors, 25 defects remain of the baseline's 316. One is a
143
+ table title straddling Camelot's header-row edge (practice us-008). The other 24 are places where
144
+ the two engines read the text layer differently and the verifier has no benign class for yet: a
145
+ symbol font's glyphs, dashes and tildes on one old page, a word PDFium does not show, a run of
146
+ digits it reads as one value. The baseline run above counts all 316. The fixes since it
147
+ (overprinted text, Type 3 and Dingbats glyphs, thin rules drawn as fills, grids over frames and
148
+ charts, table text taken as furniture) were designed while looking at those documents, so the run
149
+ after them is labelled as tuned on them.
150
+ - **Left-to-right scripts only.** Right-to-left and bidirectional text is not reordered in v0.1.
151
+ - **Unruled tables have no row spans.** A label centred beside several rows splits across them
152
+ (`Charge per` / `executed order`), and a label whose value is centred beside it needs the rows to be
153
+ set apart by more than the label's own line pitch. A fee set on two lines around a one-line label
154
+ (`€10 per million on the value` / `above €20,000`) splits from it, never into another fee's cell.
155
+ - **A table needs a value.** A table of text alone with no ruled grid reads as paragraphs, and a
156
+ caption set directly above a table can join it as its first row.
157
+ - **A table beside a prose column can come apart.** Lines are grouped across the whole page before
158
+ columns are found, so on a two-column page a prose line level with two table rows can chain them
159
+ into one line. Those rows read as interleaved text, and the unruled stage reports them with
160
+ `table_left_as_text`.
161
+ - **A call resolves forward only**, and a label the document prints on several notes resolves only
162
+ on its own page or the next (for a table that continues, from its last part). A note printed before
163
+ its call, or far from a numbering that restarts, leaves the call unresolved, with a warning. A
164
+ parenthesised number anchored on a capitalised noun is a call by the drafting conventions, so an
165
+ in-text reference like `Criteria (2)` can link to note 2.
166
+ - **Only page breaks join.** A paragraph continued in the next column, and list items or notes that
167
+ continue on the next page, stay separate blocks.
168
+ - **Lists set in the Symbol font read as paragraphs.** Their bullet is a private-use character, which
169
+ the reader drops as invisible.
170
+ - **Untrusted PDFs belong in a separate process.** MuPDF parses in memory, and a library cannot bound
171
+ its memory or time. Read hostile input in a worker process with resource limits.
172
+
173
+ ## The unique angle
174
+
175
+ Vision and OCR parsers read pixels, so they can misread a glyph or drop a value, and nothing downstream
176
+ can tell. inkgrid commits to four guarantees instead:
177
+
178
+ 1. **No invented text.** Every output character is a codepoint the PDF encodes.
179
+ 2. **No lost or doubled text.** Each document carries a proof that every word is owned exactly once.
180
+ 3. **Independently verified structure.** A verifier re-reads the PDF with a different engine,
181
+ PDFium, without importing the code that built the document. Every character PDFium reads must
182
+ belong to exactly one word that holds it, every cell must hold the ink inside its rectangle, no
183
+ drawn rule may divide a cell, and every value must be bound to one cell. On the 42 fee schedules
184
+ of the look-back corpus, all 1,817,075 characters are accounted for, and 8 cells are reported
185
+ (`docs/specs/10-verify.md` § 0); re-checked for 0.1.0 on 2026-10-02, with the same result.
186
+ 4. **Never silent.** Anything degraded becomes a typed finding on the result.
187
+
188
+ Whether this beats OCR and vision parsers at *table structure* was an open question, and the public
189
+ evidence favored the vision hybrids. The benchmark (below) bears that out for Docling, the strongest
190
+ hybrid measured. On 11 fee schedules no fix has seen, no difference between inkgrid's table structure
191
+ and Docling's is distinguishable from 0, but Docling places tables better, finds more of their values,
192
+ and misreads fewer glyphs; on the public sets, where inkgrid is tuned, Docling is ahead on every
193
+ headline score. inkgrid is ahead of unstructured on both, of marker on the held-out set, and of the
194
+ other text-layer readers on the scores the tables below mark. It does not beat Docling; what it offers
195
+ instead is the four guarantees, in a 20th to a 35th of Docling's time per page.
196
+
197
+ ## Quick start
198
+
199
+ ```bash
200
+ pip install inkgrid # or, in a uv project: uv add inkgrid
201
+ inkgrid read path/to/file.pdf -o doc.json --markdown out.md --inspector out.html
202
+ inkgrid verify path/to/file.pdf doc.json --inspector checked.html # exit 1 on any defect
203
+ inkgrid words path/to/file.pdf --pretty # the raw page model, for debugging a reading
204
+ ```
205
+
206
+ To work on inkgrid itself, from a clone: `uv sync --all-groups`, then the same commands under
207
+ `uv run`.
208
+
209
+ From Python:
210
+
211
+ ```python
212
+ import inkgrid
213
+
214
+ doc = inkgrid.read("fees.pdf") # a path, bytes, or a binary stream
215
+ for block in doc.blocks:
216
+ print(block.id, block.kind, block.text)
217
+ for finding in doc.findings:
218
+ print(finding.severity, finding.code, finding.detail)
219
+ print(doc.to_markdown())
220
+
221
+ doc = inkgrid.read("fees.pdf", strict=True) # raises StrictModeError on an error finding
222
+
223
+ report = inkgrid.verify(doc, "fees.pdf") # PDFium's reading against the document's
224
+ for defect in report.defects: # lost, invented, text, vrule, value, ...
225
+ print(defect.page, defect.code, defect.block, defect.cell, defect.detail)
226
+ print(report.ok)
227
+ ```
228
+
229
+ `inkgrid.read_pages()` returns the raw page model (words, rules, measurements) for debugging. An
230
+ encrypted PDF takes `password=`. On the command line, the password is read from stdin with
231
+ `--password-stdin`, never from arguments, because arguments show up in the process list.
232
+
233
+ ## Benchmarks / Evals
234
+
235
+ **The headline: 11 fee schedules no fix has seen** (M5d). Fee schedules from 11 exchange groups that
236
+ none of inkgrid's 42 tuning documents comes from, chosen by a rule fixed before any tool read them,
237
+ three seeded pages each: 31 pages and 53 tables. Each table's grid was drafted from the page's
238
+ rendering, never from any tool's reading; each cell's text is the PDF's own words; and every table,
239
+ with 200 cells glyph by glyph, was checked by hand. inkgrid reads them as it read the tuned run below.
240
+ Every tool reads each whole document (146 pages); only its tables on the scored pages count. Eleven
241
+ documents give wide intervals. The protocol:
242
+ [`docs/specs/16-held-out-fee-set.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/docs/specs/16-held-out-fee-set.md); the full report, with every
243
+ metric and paired difference:
244
+ [`bench/results/2026-10-01-fb58b0a-heldout/report.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/bench/results/2026-10-01-fb58b0a-heldout/report.md).
245
+
246
+ | Tool | Structure F | Table regions F | Values bound to all labels | Values found | Cell CER (lower is better) | Seconds per page |
247
+ |---|---|---|---|---|---|---|
248
+ | **inkgrid** | 0.647 [0.418, 0.849] | 0.818 [0.655, 0.945] | 0.579 [0.363, 0.793] | 0.704 [0.451, 0.899] | 0.098 [0.029, 0.240] | 0.294 [0.211, 0.434] |
249
+ | Docling | 0.708 [0.521, 0.867] | 0.895 [0.747, 1.000] † | 0.743 [0.593, 0.865] | 0.932 [0.834, 0.988] † | 0.024 [0.003, 0.055] † | 10.158 [9.054, 11.836] † |
250
+ | Docling, image-only (OCR) | 0.707 [0.519, 0.862] | 0.862 [0.661, 1.000] | 0.772 [0.592, 0.892] | 0.923 [0.817, 0.983] † | 0.035 [0.011, 0.083] | 18.404 [12.969, 22.064] † |
251
+ | marker | 0.234 [0.114, 0.364] † | 0.850 [0.709, 0.953] | 0.153 [0.047, 0.272] † | 0.459 [0.249, 0.647] | 0.465 [0.273, 0.618] † | 0.566 [0.489, 0.732] † |
252
+ | unstructured `hi_res` (OCR) | 0.336 [0.206, 0.473] † | 0.905 [0.839, 0.962] | 0.082 [0.017, 0.175] † | 0.612 [0.388, 0.810] | 0.259 [0.147, 0.352] | 5.327 [4.824, 6.194] † |
253
+ | Camelot | 0.476 [0.202, 0.725] | 0.725 [0.496, 0.900] | 0.350 [0.114, 0.627] † | 0.357 [0.115, 0.641] † | 0.308 [0.097, 0.635] † | 0.284 [0.263, 0.326] |
254
+ | PyMuPDF | 0.490 [0.259, 0.711] | 0.776 [0.558, 0.939] | 0.320 [0.105, 0.592] † | 0.343 [0.113, 0.622] † | 0.477 [0.222, 0.703] † | 0.070 [0.058, 0.086] † |
255
+ | pdfplumber | 0.498 [0.263, 0.711] | 0.714 [0.480, 0.900] | 0.320 [0.105, 0.592] † | 0.343 [0.113, 0.622] † | 0.520 [0.241, 0.760] † | 0.096 [0.080, 0.113] † |
256
+ | inkgrid on Tesseract's words | 0.368 [0.218, 0.532] † | 0.809 [0.673, 0.928] | 0.136 [0.028, 0.305] † | 0.468 [0.291, 0.650] † | 0.144 [0.071, 0.265] | 1.681 [1.575, 1.912] † |
257
+
258
+ A † marks a tool whose paired difference from inkgrid on that metric has an interval excluding 0.
259
+ "Values bound to all labels" and "values found" score the truth's access paths (each value with its row
260
+ and column labels); cell CER counts glyph errors on the 200 checked cells, each against the closest
261
+ cell of the tool's matching table, so a missed table costs all its glyphs. Docling reads the text layer
262
+ through its layout and table models, with OCR only where a page has none; its image-only run, and
263
+ unstructured, read every page with OCR; marker reads with OCR off. The heavy tools' seconds are a warm
264
+ process's on a 16-thread CPU, their models loaded.
265
+
266
+ - **Against the other text-layer readers** (Camelot, PyMuPDF, pdfplumber): inkgrid files more values
267
+ under all their labels (0.579 against 0.320 to 0.350) and finds more of them (0.704 against 0.343 to
268
+ 0.357), misreads fewer glyphs (0.098 against 0.308 to 0.520), and its structure recall is higher
269
+ (0.628 against 0.402 to 0.443). Its structure F and table regions are not distinguishable from theirs.
270
+ - **Against Docling:** no difference in structure (F 0.647 against 0.708) or in values bound to all
271
+ their labels (0.579 against 0.743) is distinguishable from 0. Docling places tables better (regions F
272
+ 0.895 against 0.818), finds more values (0.932 against 0.704), and misreads fewer glyphs (0.024
273
+ against 0.098), at 10.2 seconds a page to inkgrid's 0.29. On the image-only copy, Docling finds more
274
+ values (0.923); no other difference from inkgrid is distinguishable, at 18.4 seconds a page.
275
+ - **Against marker and unstructured `hi_res`:** inkgrid's structure (F 0.647 against 0.234 and 0.336)
276
+ and values bound to all their labels (0.579 against 0.153 and 0.082) are higher, and marker misreads
277
+ more glyphs (0.465); no difference in table regions is distinguishable.
278
+ - **What the text layer gives inkgrid:** fed Tesseract's words instead of the PDF's, inkgrid's
279
+ structure F falls from 0.647 to 0.368 and its values bound to all their labels from 0.579 to 0.136,
280
+ and it runs 5.7 times slower.
281
+ - **Where inkgrid fails:** on three of the 11 documents (Borsa Istanbul's data fees, JPX's participant
282
+ fees, LuxSE's listing fees) it binds no value to all its labels and gets few of the tables' cell
283
+ relations right; they hold 283 of its 407 misread glyphs.
284
+ - **The verifier** reports 8 cells on these documents, all on one (PSX's trading fees, pages 3 and 4),
285
+ each a word inkgrid's grid cuts in two. **Camelot refuses one document** whose permissions forbid
286
+ text extraction (SET's TFEX fees), scored as no tables; every other tool reads all 11.
287
+ - **The truth shares the text layer:** its cell text is the characters inkgrid and the other
288
+ text-layer readers also read, so an OCR tool can misread a glyph where they cannot. Values found and
289
+ cell CER compare the two kinds of tool on those terms.
290
+
291
+ **Two runs against other text-layer readers** (pdfplumber 0.11.10, PyMuPDF 1.28.2's
292
+ `find_tables()`, and Camelot 2.0.0's lattice, each with its defaults). The **baseline** is inkgrid's
293
+ reading before any fix for the reading errors its verifier found on these same documents; the one
294
+ change since M4 stops a crash on a Camelot table with no cells. The **tuned** run is its reading after
295
+ the M5c fixes, which were designed while looking at those errors, so it overstates how inkgrid reads
296
+ documents no fix has seen (DR-0023). The peers are measured again in each run, and their numbers are
297
+ identical. Each number is a point estimate with its 95% interval from 10,000 resamples of documents.
298
+ The protocol was fixed before anything was scored:
299
+ [`docs/specs/12-benchmark.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/docs/specs/12-benchmark.md). The full reports, with every metric and
300
+ every paired difference, are [`bench/results/latest.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/bench/results/latest.md) (tuned, with the
301
+ heavy tools below) and
302
+ [`bench/results/2026-09-29-b33b3cb/report.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/bench/results/2026-09-29-b33b3cb/report.md) (baseline).
303
+
304
+ | Dataset (documents) | Metric | inkgrid, tuned | inkgrid, baseline | Camelot | PyMuPDF | pdfplumber |
305
+ |---|---|---|---|---|---|---|
306
+ | ICDAR-2013 competition (67) | Structure F, the competition's scorer | **0.788** [0.711, 0.861] | **0.777** [0.700, 0.850] | 0.637 [0.531, 0.735] | 0.611 [0.512, 0.702] | 0.591 [0.493, 0.683] |
307
+ | ICDAR-2013 competition (67) | F1-TEDS, Soric et al.'s protocol | 0.628 [0.466, 0.788] | 0.612 [0.455, 0.767] | 0.624 [0.494, 0.735] | 0.519 [0.384, 0.639] | 0.416 [0.287, 0.545] |
308
+ | ICDAR-2013 practice (58) | Structure F, the competition's scorer | **0.688** [0.596, 0.776] | **0.664** [0.569, 0.756] | 0.499 [0.381, 0.611] | 0.457 [0.346, 0.568] | 0.420 [0.314, 0.527] |
309
+ | ICDAR-2013 practice (58) | Values filed under their labels | **0.323** [0.199, 0.488] | **0.309** [0.188, 0.469] | 0.251 [0.142, 0.393] | 0.239 [0.135, 0.382] | 0.172 [0.093, 0.280] |
310
+ | olmOCR-bench tables (188) | Table tests passed | **0.486** [0.415, 0.558] | **0.481** [0.410, 0.552] | 0.273 [0.213, 0.338] | 0.321 [0.256, 0.390] | 0.294 [0.231, 0.361] |
311
+
312
+ A bold number is ahead of all three peers in its run, with every paired difference's interval
313
+ excluding 0.
314
+
315
+ - **Where inkgrid is ahead, in both runs:** table structure by the ICDAR competition's own scorer, on
316
+ both sets, mostly through recall; the olmOCR-bench table tests, on all 188 PDFs and on the 173 with
317
+ a text layer; and values filed under their row and column labels (the practice set's 5,603
318
+ checkable access paths). That last metric tops out at 0.868 even when the ground truth itself is
319
+ read as a tool, partly because some labels sit outside the value's row and column.
320
+ - **Where it is not:** under Soric et al.'s protocol no difference from Camelot or PyMuPDF is
321
+ distinguishable from 0 in either run, and inkgrid is ahead of pdfplumber on the three structure
322
+ scores but not on table boxes. At the baseline, its pooled structure precision on the competition
323
+ set was below Camelot's (0.794 against 0.934), and so was its region precision on the practice set
324
+ (0.873 against 0.940); in the tuned run neither difference is distinguishable from 0 (0.802, and
325
+ 0.895).
326
+ - **Tuned against baseline:** every tuned number in the table is higher, but the two runs are not
327
+ paired here and each row's intervals overlap, so this benchmark does not show that the fixes
328
+ raised these scores. What they did remove, by design, is what the verifier finds on these
329
+ documents: 323 defects on 34 of the 313 at the baseline, 32 on 7 in the tuned run.
330
+ - **It is the slowest:** 0.35 to 0.53 seconds per page in the tuned run (0.34 to 0.55 at the
331
+ baseline), against 0.28 to 0.36 for Camelot and 0.07 to 0.12 for PyMuPDF and pdfplumber (timings
332
+ move about 10% between two runs of the same code). inkgrid runs Camelot's lattice for ruled grids,
333
+ so its comparison with Camelot measures what inkgrid adds on top of it.
334
+ - **No tool crashed** on any of the 313 documents in either run. Soric et al.'s own released
335
+ predictions, re-scored here with their evaluator, reproduce their results within 0.01 on 15 of 16
336
+ numbers (PyMuPDF's F1-TEDS misses by 0.00002).
337
+
338
+ **OCR, vision, and hybrid parsers on the public sets** (M5d-1). The tuned run again, at `bea4c95`,
339
+ with Docling (by default and on an image-only copy of each PDF), marker (OCR off), unstructured
340
+ `hi_res` (Tesseract), and inkgrid on Tesseract's words added; inkgrid's and the three peers' counts are
341
+ identical to the first tuned run's. inkgrid is tuned on these documents and the heavy tools are not,
342
+ so the comparison flatters inkgrid. A † marks a tool whose paired difference from inkgrid has an
343
+ interval excluding 0. The full report is [`bench/results/latest.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/bench/results/latest.md).
344
+
345
+ | Dataset (documents) | Metric | inkgrid, tuned | Docling | Docling, image-only (OCR) | marker | unstructured `hi_res` (OCR) | inkgrid on Tesseract's words |
346
+ |---|---|---|---|---|---|---|---|
347
+ | ICDAR-2013 competition (67) | Structure F, the competition's scorer | 0.788 [0.711, 0.861] | 0.901 [0.857, 0.937] † | 0.880 [0.827, 0.924] † | 0.768 [0.697, 0.833] | 0.467 [0.398, 0.534] † | 0.354 [0.280, 0.428] † |
348
+ | ICDAR-2013 competition (67) | F1-TEDS, Soric et al.'s protocol | 0.628 [0.466, 0.788] | 0.894 [0.846, 0.934] † | 0.881 [0.829, 0.923] † | 0.759 [0.682, 0.827] | 0.585 [0.510, 0.654] | 0.341 [0.245, 0.446] † |
349
+ | ICDAR-2013 practice (58) | Structure F, the competition's scorer | 0.688 [0.596, 0.776] | 0.847 [0.775, 0.910] † | 0.829 [0.763, 0.888] † | 0.783 [0.708, 0.850] † | 0.468 [0.397, 0.541] † | 0.328 [0.253, 0.404] † |
350
+ | ICDAR-2013 practice (58) | Values filed under their labels | 0.323 [0.199, 0.488] | 0.494 [0.329, 0.708] † | 0.473 [0.314, 0.672] † | 0.303 [0.183, 0.464] | 0.074 [0.035, 0.131] † | 0.065 [0.024, 0.126] † |
351
+ | olmOCR-bench tables (188) | Table tests passed | 0.486 [0.415, 0.558] | 0.744 [0.692, 0.792] † | 0.736 [0.683, 0.787] † | 0.460 [0.397, 0.521] | 0.413 [0.359, 0.468] | 0.303 [0.252, 0.358] † |
352
+ | ICDAR-2013 practice (58) | Seconds per page | 0.427 [0.374, 0.486] | 8.254 [6.876, 9.904] † | 13.525 [12.634, 14.583] † | 0.451 [0.440, 0.464] | 5.186 [4.962, 5.443] † | 2.226 [2.070, 2.391] † |
353
+
354
+ - **Docling is ahead of inkgrid on every one of these**, in both its runs: structure F by 0.09 to
355
+ 0.16, F1-TEDS by 0.25 to 0.27, values filed under their labels by 0.15 to 0.17, and olmOCR-bench's
356
+ table tests by 0.25 to 0.26, at 8.3 and 13.5 seconds a page on the practice set to inkgrid's 0.43.
357
+ - **marker** is ahead on the practice set's structure (0.783 against 0.688); no other difference
358
+ from inkgrid is distinguishable, its speed included. On the held-out fee set it is far behind.
359
+ - **unstructured `hi_res`** trails inkgrid on structure, on both sets, and on values filed under
360
+ their labels; on F1-TEDS and olmOCR-bench no difference is distinguishable.
361
+ - **On Tesseract's words**, inkgrid trails itself on every metric (structure F 0.354 and 0.328,
362
+ against 0.788 and 0.688): the text layer, not the gridders alone, is what puts inkgrid where it is.
363
+ - No heavy tool crashed or timed out on any of the 313 documents.
364
+
365
+ Not measured: FinTabNet, of which no official source of the PDFs remains.
366
+
367
+ ## Architecture
368
+
369
+ A pure core sits between two thin shells. Only `inkgrid.read` touches MuPDF and Camelot, and only
370
+ `inkgrid.verify`'s reader touches PDFium; a test enforces the layer table on the import graph. The
371
+ verifier cannot import the code that builds the output, so its independence is structural. Details:
372
+ [`docs/ARCHITECTURE.md`](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/docs/ARCHITECTURE.md).
373
+
374
+ ## Tech stack
375
+
376
+ Python 3.12+, PyMuPDF 1.28 (text layer and drawings), Pydantic v2 (frozen, validated models), uv
377
+ with the `uv_build` backend, Ruff, mypy `--strict`, pytest 9 with Hypothesis, and GitHub Actions.
378
+ Camelot 2.0 reads ruled tables. The default engine is `lattice="combined"`, because it never fused or
379
+ split a cell across 42 measured fee schedules. `"vector"` is about twice as fast end to end, but it
380
+ malformed cells on 3% of their ruled pages, mostly tables drawn as Word draws borders. `"raster"` is
381
+ also available. pypdfium2 5.13 (PDFium) powers the independent verifier.
382
+
383
+ ## Deployment
384
+
385
+ inkgrid is a library, so there is nothing to deploy. A release is a version tag: `release.yml` checks
386
+ that the tag is the project's final version with a dated changelog section, runs the lint, type, and
387
+ test gates and the distribution smoke tests on Linux (CI on `main` covers the other platforms), builds
388
+ the sdist and wheel, and uploads those files to PyPI with the repository's API token; a `testpypi-v`
389
+ tag rehearses the same on TestPyPI (`docs/specs/17-release.md`). Versions are bumped with
390
+ `uv version --bump`.
391
+
392
+ ## License
393
+
394
+ inkgrid's own code is MIT; see [LICENSE](https://github.com/Abdul-Muizz1310/inkgrid/blob/v0.1.0/LICENSE).
395
+
396
+ **Read this before you ship inkgrid in a product.** inkgrid requires
397
+ [PyMuPDF](https://pymupdf.readthedocs.io/en/latest/about.html#license-and-copyright), which Artifex
398
+ dual-licenses under the GNU AGPL-3.0 or a commercial license. Software distributed or served over a
399
+ network with inkgrid must meet the AGPL's terms for the combined work, unless you hold Artifex's
400
+ commercial license. This is not legal advice.
401
+
402
+ The other dependencies are permissively licensed: Camelot (MIT), Pydantic (MIT), OpenCV (Apache-2.0),
403
+ pypdfium2 (Apache-2.0 or BSD-3-Clause), pandas and NumPy (BSD-3-Clause). OpenCV is pinned away from
404
+ 5.0.0.93, whose bundled FFmpeg lacks the CVE-2026-8461 fix.