Every fixture, test document, tool example and document now uses an invented kitchen-and-baking handbook series, written in this repository. The package's behaviour is unchanged; src/ changes are comments and help text only. - Generated fixtures are regenerated from their generators. Their structural counts are identical before and after: elements, images, rows, cells, headings, bookmarks and the witness inventory's per-document totals. The image-inbox and accounting documents are renamed kapittel-84-*. - tools/okf_accounting_gate.py: the two options that named one real corpus each are replaced by a generic, repeatable --corpus PATH with no default. Row 5 compares the PDF pair alone. Gate verdict unchanged: RED rows 2, 3, 6. - tools/okf_witness.py: the STS JSON reader for one publisher's delivery is removed, along with its three twins and five tests. The mutation harness loses W09. - docs/: 13 dated reports that documented runs on a retired reference corpus are removed, and 40 are neutralized. Dead links are removed, and no new dangling path is introduced. - The synthetic MCP-gate corpus and the residual probe words are neutral. Valgt: keep the `okf quality --fasit` bar value (the measured fraction, one corpus) and rewrite only its provenance, because the verdict stays unchanged and the number names nothing. Term check with the local list: 0 of 411 tracked files, 0 file names, 0 of 27 binary fixtures. Suite after git add: 2457 passed, 1 skipped. The base tree had 2460 passed and 2 skipped; five tests went with the JSON reader and four were added by the term check. ruff, ruff format and mypy --strict src/ are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
13 KiB
Markdown
245 lines
13 KiB
Markdown
# G2 measurement: one handbook PDF page through the extraction registry
|
|
|
|
**Date:** 2026-08-21 · **Order:** `20260821T170054Z-486638087-from-.claude` (from `.claude`)
|
|
**Question this document answers:** is a PDF parser behind the `[extract]` extra a small,
|
|
bounded job, or is it a project?
|
|
|
|
This is a measurement, not an implementation. No production code was written, no version
|
|
bumped, no tag cut, no guard pin moved. Filename is English per this repo's docs convention;
|
|
the order suggested a Norwegian one. Grep anchor for the gap register: **G2**.
|
|
|
|
## Verdict
|
|
|
|
**Two answers, and the fork is the finding — but only one of the two branches is needed
|
|
to unblock the trial stone.**
|
|
|
|
1. **Text extraction is small.** One extractor function plus an import probe, at a seam that
|
|
is two source lines wide. One library (`pdfplumber`, MIT) reads 308 of 308 pages of the
|
|
measured handbook, is deterministic across runs, and recovers the requirement table on the
|
|
sample page as correctly paired text lines (4 of 4 rows). This is a bounded job.
|
|
2. **Recovering tables as *structured* markdown is a project, and it is not a parser choice.**
|
|
`pdfplumber.extract_tables()` and `PyMuPDF.find_tables()` — two independent
|
|
implementations — return the *same* wrong structure for the sample table: one 2x6 grid
|
|
where four logical rows collapse into a single cell with embedded newlines. The breakage
|
|
is in the document's ruling geometry, not in either parser. Fixing it means layout
|
|
heuristics this repo would own, specify and test. Across the whole handbook, only
|
|
**45 of 196** detected table objects are structurally clean enough to hand to
|
|
`render_table` unchanged.
|
|
|
|
**Recommendation: take branch 1, decline branch 2 for now.** The handbook enters the chain as
|
|
prose with correctly paired table lines, which is what the trial stone needs to reach leg 3.
|
|
Branch 2 is a separate decision that nothing currently waits on.
|
|
|
|
**The choice of library is forced by branch 1, not free.** Cheaper candidates parse the page
|
|
but destroy the requirement table's row-to-value pairing — in a `krav` document that is a
|
|
correctness hazard, not a cosmetic one. See [Table pairing](#table-pairing-the-decisive-number).
|
|
|
|
## What was measured
|
|
|
|
| Item | Value |
|
|
|---|---|
|
|
| Source | a public Norwegian technical handbook, one edition, PDF |
|
|
| Book bytes | 9 879 066 |
|
|
| Book pages | 308 |
|
|
| Sample page | page index 150 (printed page 151), in a chapter on dimensioning |
|
|
| Sample bytes | 316 356 |
|
|
| Why this page | running prose + one numbered requirement table (`Tabell 524.1`) + one real figure (`Figur 524.1`) + `skal`-requirements |
|
|
|
|
The PDF lives under a scratch path, not in this repo — it is test material, not content.
|
|
Everything below ran on Python 3.14.0 in throwaway virtualenvs; nothing was added to this
|
|
repo's environment or its dependency set.
|
|
|
|
## Probe 1 — the registry as it stands today
|
|
|
|
`extract_text` called on the page, with two known-positive controls **in the same call**, so
|
|
the negative result is measured rather than assumed:
|
|
|
|
```
|
|
handbook-page.pdf: RAISED ExtractionError code='extractor_extra_missing' msg=extracting '.pdf'
|
|
requires the optional 'extract' extra (pip install 'llm-ingestion-okf[extract]');
|
|
it is not installed
|
|
control.md: OK 48 chars -> '# Kontroll\n\nDette er en kjent-positiv kontroll.\n'
|
|
control.csv: OK 54 chars -> '| krav | verdi |\n| --- | --- |\n| Q200 5.4.1 | fritt |\n'
|
|
```
|
|
|
|
The probe can succeed; the `.pdf` rejection is real and typed, not a broken probe.
|
|
|
|
## Probe 2 — the full Door B path
|
|
|
|
The same page dropped in an inbox next to a `.md` control, through `process_inbox` with the
|
|
real guard adapter:
|
|
|
|
```
|
|
persisted : [PersistedFile(source_file='kontroll.md',
|
|
path=.../bundle/inbox-kontroll.md, reasons=('clean: no findings',))]
|
|
failed : [FailedFile(source_file='handbook-page.pdf',
|
|
error=ExtractionError("extracting '.pdf' requires the optional 'extract' extra ..."))]
|
|
rejected : () quarantined: ()
|
|
```
|
|
|
|
The behaviour is exactly what the contract promises: one bad file does not abort the run, the
|
|
control persists and is indexed, and the PDF is reported per-file. Nothing here is a defect —
|
|
leg 2 stops because the extra ships no parser, by design.
|
|
|
|
## Candidates
|
|
|
|
License, release date and required dependencies read from PyPI metadata; package count and
|
|
disk size measured by installing each into a clean virtualenv and listing it.
|
|
|
|
| Candidate | Version | Released | License | Packages installed | site-packages |
|
|
|---|---|---|---|---|---|
|
|
| `pypdf` | 6.16.1 | 2026-08-14 | BSD-3-Clause | 1 | 16 MB |
|
|
| `pdfminer.six` | 20260107 | 2026-01-07 | MIT | 5 | 32 MB |
|
|
| `pdfplumber` | 0.11.10 | 2026-06-15 | MIT | 8 | 56 MB |
|
|
| `pymupdf` | 1.28.2 | 2026-08-06 | AGPL-3.0 **or** Artifex commercial | 1 | 71 MB |
|
|
|
|
Three facts that matter more than the table:
|
|
|
|
- **`pymupdf` is a licensing decision, not a technical one.** AGPL-3.0 propagates to whoever
|
|
installs the extra. This repo is MIT; shipping an AGPL default in an optional extra hands a
|
|
consumer a copyleft obligation they did not choose. It is out on that ground alone, and it
|
|
performs no better on the sample page than the MIT alternatives.
|
|
- **`pdfplumber` pins `pdfminer.six==20260107` exactly.** A hard `==` in a transitive
|
|
dependency is a co-installation hazard for any consumer that already uses `pdfminer.six`.
|
|
- **`pdfplumber` pulls binary wheels** (`pillow`, `pypdfium2`). Acceptable behind an opt-in
|
|
extra; it would not be acceptable in core, and this repo's "exactly one runtime dependency"
|
|
rule is untouched either way.
|
|
|
|
For completeness, the other two types the extra covers: `python-docx` 1.2.0 (MIT, 3 packages,
|
|
26 MB, pulls `lxml`) and `openpyxl` 3.1.5 (MIT, 2 packages, 15 MB). Both are materially
|
|
simpler problems than PDF — the source formats are already structured XML — so they do not
|
|
change the verdict, but they do mean `[extract]` is three parsers, not one.
|
|
|
|
## What each candidate does to this page
|
|
|
|
`pdfplumber.extract_text()` gave about 30 lines, not reproduced here (they are the
|
|
source's own text): a chapter heading, a section heading, four paragraphs of
|
|
`skal`-requirements, the caption and four rows of `Tabell 524.1` -- each label with its
|
|
value on one line -- a figure caption, two more paragraphs and the running footer.
|
|
|
|
That is readable Norwegian technical prose, in reading order, with the table's rows intact as
|
|
lines. Two fidelity defects are visible and neither is fatal: the subscript in `F10` breaks
|
|
across two lines, and the figure is gone — only its caption survives, because the figure is a
|
|
vector drawing, not text. A figure whose content is only in the drawing is lost at leg 2
|
|
regardless of parser, and that is worth knowing before anyone promises a complete bundle.
|
|
|
|
Character counts on the same page: `pypdf` 1819, `pdfminer.six` 1834, `pdfplumber` 1761,
|
|
`pymupdf` 1820. **Volume is not the discriminator — order is.**
|
|
|
|
### Table pairing: the decisive number
|
|
|
|
`Tabell 524.1` has four requirement rows (a class → a maximum value). Counting how many
|
|
survive with label and value on the same line:
|
|
|
|
| Parser | Paired rows |
|
|
|---|---|
|
|
| `pdfplumber.extract_text()` | **4 / 4** |
|
|
| `pypdf.extract_text()` | 0 / 4 |
|
|
| `pdfminer.six.extract_text()` | 0 / 4 |
|
|
| `pymupdf.get_text()` | 0 / 4 |
|
|
|
|
The three zero-scorers emit all four labels, then all four values, as separate blocks. A
|
|
downstream reader — human or model — can only re-pair them by guessing. In a requirements
|
|
document, a row's value turning into its neighbour's value is a wrong answer that looks right.
|
|
This is why the cheapest candidate is not the correct one.
|
|
|
|
### What happens to the table as *structure*
|
|
|
|
Feeding `render_table` needs rows and cells, not lines. Both structural extractors return the
|
|
same thing for this table:
|
|
|
|
```
|
|
pdfplumber.extract_tables() -> 1 table, 2 rows x 6 cols
|
|
['', '<label header>', '', '', '<value header>', '']
|
|
['<label 1>\n<label 2>\n<label 3>\n<label 4>', None, None,
|
|
'<value 1>\n<value 2>\n<value 3>\n<value 4>', None, None]
|
|
|
|
pymupdf.find_tables() -> 1 table, 2 rows x 6 cols (byte-identical shape)
|
|
```
|
|
|
|
Four logical rows collapse into one cell each side; three of six columns are empty padding.
|
|
`render_table` would faithfully render a two-row table that misrepresents the requirement.
|
|
Two independent implementations agreeing on the same wrong shape is the evidence that this is
|
|
the document's geometry, not a library bug — the handbook draws ruling lines around blocks, not rows.
|
|
|
|
**So: handbooks of this kind are usable as prose, and are not usable as structured concept tables
|
|
without a layer this repo does not have.**
|
|
|
|
## Determinism
|
|
|
|
Two runs of each configuration, compared byte-for-byte:
|
|
|
|
```
|
|
pypdf identical=True
|
|
pdfminer.six identical=True
|
|
pdfplumber.text identical=True
|
|
pdfplumber.tables identical=True
|
|
pdfplumber slice[140:160] identical=True
|
|
```
|
|
|
|
Within one parser version, extraction is deterministic — the bit-exact promise survives.
|
|
**Across parser versions it is not guaranteed** (ASSUMED, not measured): `pdfminer.six` uses
|
|
date-stamped releases with no stability contract, so any golden fixture built on extracted
|
|
text would be pinned to an exact parser version, and a parser upgrade becomes a fixture
|
|
migration. That is a real, recurring maintenance cost and it belongs in the decision.
|
|
|
|
## Denominators
|
|
|
|
Whole-book run, all 308 pages:
|
|
|
|
| Measure | Count |
|
|
|---|---|
|
|
| Pages total | 308 |
|
|
| Pages yielding non-empty text | **308 / 308** |
|
|
| Lines matching `^Tabell <n>` (tables the document claims) | 149 |
|
|
| Table objects detected by `extract_tables()` | 196 |
|
|
| Detected tables clean enough for `render_table` unchanged | **45 / 196** |
|
|
|
|
"Clean enough" is defined in the measurement script, not by eye: at least two rows,
|
|
rectangular, no `None` cell, no cell containing a newline. Anything failing that would either
|
|
crash or silently misrepresent if handed to `render_table`.
|
|
|
|
Scope of these denominators: **one handbook, one edition**. They are not a
|
|
claim about its series as a whole. Nothing here was measured on a scanned or image-only
|
|
PDF; every page of this book carries a text layer, and a scanned handbook would score 0 and
|
|
need OCR — a different project entirely.
|
|
|
|
## Size of the wiring
|
|
|
|
The seam is small and already shaped for this. `grep` finds the gate at exactly two source
|
|
lines — `extract.py:30` (`_OPTIONAL_EXTENSIONS`) and `extract.py:135` (the raise) — plus two
|
|
test files asserting the current rejection (`tests/test_extract.py`,
|
|
`tests/test_error_codes.py`). Adding a type means one `_extract_pdf(data)` function in the
|
|
same shape as `_extract_csv`, an import probe replacing the frozenset membership test, and
|
|
the same error code kept for the still-uninstalled case. The module docstring already
|
|
describes this exact transition.
|
|
|
|
**The wiring is hours. The cost is the choice above it**: which library, which fidelity
|
|
promise, which fixtures, and what happens to figures and tables that do not survive.
|
|
|
|
## What this order deliberately did not do
|
|
|
|
- No parser implemented, no `[extract]` populated, no error message changed.
|
|
- No version bump, no tag, no publish, no guard pin move.
|
|
- No writes in any other repo. The PDF stays in scratch.
|
|
|
|
## Verification log
|
|
|
|
| # | Claim | Status | Evidence |
|
|
|---|---|---|---|
|
|
| 1 | The handbook PDF is public and downloadable from its publisher | VERIFIED | `curl -L -w '%{http_code} %{content_type} %{size_download}'` → `200 application/pdf 9879066`; magic bytes `%PDF-1.6` |
|
|
| 2 | Book has 308 pages; sample is index 150 | VERIFIED | `pypdf.PdfReader(...).pages` length; `makepage.py` |
|
|
| 3 | `.pdf` fails with `extractor_extra_missing`, controls pass in the same call | VERIFIED | `probe_registry.py` output, quoted above |
|
|
| 4 | Full Door B path reports the PDF as `failed` and persists the control | VERIFIED | `probe_inbox.py` output, quoted above |
|
|
| 5 | `[extract]` is declared and empty | VERIFIED | `pyproject.toml` `[project.optional-dependencies] extract = []` |
|
|
| 6 | Candidate licenses and release dates | VERIFIED | PyPI JSON API per package (`pypi.org/pypi/<name>/json`) |
|
|
| 7 | Package counts and disk sizes | VERIFIED | one clean `python3 -m venv` per candidate; `pip list --format=freeze`, `du -sk` |
|
|
| 8 | `pdfplumber` pins `pdfminer.six==20260107` | VERIFIED | PyPI `requires_dist` |
|
|
| 9 | Table row pairing 4/4 vs 0/4 | VERIFIED | `pairing.py` / `pairing_mupdf.py`, ground truth read off the rendered page |
|
|
| 10 | `extract_tables()` and `find_tables()` return the same 2x6 shape | VERIFIED | `cand_pdfplumber.py`, `cand_pymupdf.py` |
|
|
| 11 | Determinism within a version, 5 configurations | VERIFIED | `determinism.py`, sha256 per run |
|
|
| 12 | 308/308 pages with text; 149 captions; 196 detected; 45 clean | VERIFIED | `denominator.py`, whole book, ~41 s |
|
|
| 13 | Gate seam is two source lines plus two test files | VERIFIED | `grep -rn '_OPTIONAL_EXTENSIONS\|extractor_extra_missing' src/ tests/` |
|
|
| 14 | Cross-version output stability is not guaranteed | ASSUMED | inferred from `pdfminer.six` date-stamped versioning; not measured across versions |
|
|
| 15 | Figures are lost because they are vector drawings | VERIFIED (this page) | caption present, no figure text in any parser's output; not generalised to the book |
|
|
| 16 | Denominators generalise beyond this handbook | NOT CLAIMED | one handbook, one edition measured |
|