llm-ingestion-okf/docs/2026-08-21-g2-pdf-extraction-measurement.md
Kjell Tore Guttormsen b73dd9d6a4 docs(extract): measure one Vegnormalene PDF page against the extraction registry
Order 20260821T170054Z-486638087-from-.claude (gap G2). Measurement only: no
parser implemented, no version bump, no pin move.

Measured on Handbok N200 Vegbygging (juli 2018), 308 pages, page index 150:
the registry rejects .pdf with extractor_extra_missing while .md/.csv controls
pass in the same call, and process_inbox reports the file as failed without
aborting the run. pdfplumber recovers Tabell 524.1 as 4/4 correctly paired text
lines where pypdf, pdfminer.six and pymupdf all score 0/4; both structural
extractors return the same wrong 2x6 grid, so table structure is the document's
geometry rather than a library defect. Whole book: 308/308 pages yield text,
45 of 196 detected tables are clean enough for render_table.

Verdict: text extraction is a small, bounded job; structured table recovery is a
separate project that nothing currently waits on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xTq1nbpz9x34udpDDExWM
2026-08-21 19:16:15 +02:00

15 KiB
Raw Blame History

G2 measurement: one Vegnormalene 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. Vegnormalene enter 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.

What was measured

Item Value
Source Håndbok N200 Vegbygging (juli 2018), Statens vegvesen, public PDF
URL https://www.vegvesen.no/globalassets/fag/handboker/hb-n200-vegbygging-juli-2018.pdf
Book bytes 9 879 066 · sha256 aec0825c305cc830f4e542062c51ec8e4499769495b7d39cb36e48079fe0fdb4
Book pages 308
Sample page page index 150 (printed page 151), ch. 5 "Dimensjonering av vegoverbygning"
Sample bytes 316 356 · sha256 95e1d28b42ec3f514a79e0ad568d91a7122530fba82828c05b859e324e0884ac
Why this page running prose + one numbered requirement table (Tabell 524.1 Krav til helning på utkilingen) + 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:

n200-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| N200 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='n200-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(), first ~30 lines, verbatim:

Kapittel 5  Dimensjonering av vegoverbygning
Utkiling
Utkiling i vegens lengderetning (overganger i undergrunn)
For ikke frostsikret veg skal det bygges en utkiling med ikke telefarlige materialer eller med
isolasjonsmaterialer for å unngå ujevne telehiv ved overgang mellom materialer med forskjellig
telefarlighet. Utkilingen går opp til underkant av forsterkningslaget. For frostsikret veg er det ikke
behov for utkilinger.
Isolasjonsmaterialene skal ligge drenert. Figur 524.1 viser prinsippskisser for utkiling med ikke
telefarlige materialer og isolasjonsplater av XPS. Også skumglass og lettklinker kan benyttes til
utkiling. Utkilingslengde L skal beregnes fra tabell 524.1 hvor frostsikringsdybden ved frostmengde
F danner utgangspunktet for beregning av utkilingslengden. Ved bruk av isolasjonsmaterialer skal
10
det benyttes et nedre frostsikringslag med tykkelse som gitt i tabell 521.2.
Utkilingslengder gjelder både for nyanlegg og utbedring av eksisterende veg.
Tabell 524.1 Krav til helning på utkilingen
Skiltet hastighet, km/t Helning på utkilingen, maks
≤ 50 og g/s  veger 1:10
60 og 70 1:15
80 og 90 1: 25
≥ 100 Frostsikret veg forutsettes
Figur 524.1 Utkiling ved overgang bergskjæring/telefarlig grunn eller underbygning
For å redusere fuktopptak i isolasjonsplater av XPS skal tykkelsen være minimum 5 cm. For å unngå å
bruke tynnere isolasjonsplater i avslutningen av en utkiling bør det vurderes å legge isolasjonsplatene
med økende mellomrom for gradvis å redusere isoleringsevnen.
Ved overgang mellom skjæring og fylling i telefarlig jord, bør utkilingen utføres med det materialet
fyllingen er bygget opp av, se figur 524.2. Største utkilingsdybde skal beregnes etter reglene i kapittel
521.
Håndbok N200 Vegbygging (juli 2018) 151

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 (speed class → maximum slope). 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, 80 og 90 → 1:25 becoming 80 og 90 → 1:10 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
['', 'Skiltet hastighet, km/t', '', '', 'Helning på utkilingen, maks', '']
['≤ 50 og g/s  veger\n60 og 70\n80 og 90\n≥ 100', None, None,
 '1:10\n1:15\n1: 25\nFrostsikret veg forutsettes', 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 — N200 draws ruling lines around blocks, not rows.

So: Vegnormalene 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 (N200, July 2018). They are not a claim about the N-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 normal 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 N200 PDF is public and downloadable from vegvesen.no 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 N200:2018 NOT CLAIMED one handbook, one edition measured