Skip to content

PDF pipeline: docling-parse line sanitizer + TableFormer + conformance push (4/14 exact) - #5

Merged
artiz merged 42 commits into
masterfrom
bugfix/pdf-parsing
Jun 29, 2026
Merged

PDF pipeline: docling-parse line sanitizer + TableFormer + conformance push (4/14 exact)#5
artiz merged 42 commits into
masterfrom
bugfix/pdf-parsing

Conversation

@artiz

@artiz artiz commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Takes the Rust PDF/image pipeline from 1/14 to 4/14 byte-exact vs live published
docling, with the non-exact PDFs' diff counts cut ~3–5×. Ports TableFormer and the
docling-parse line sanitizer, aligns table output to live docling, and adds a
performance report.

PDF conformance (vs live docling)

4/14 byte-exact: picture_classification, code_and_formula, multi_page,
2305.03393v1-pg9 (incl. its TableFormer table, cell-for-cell). Non-exact diff
counts dropped sharply: 2203 321→211, 2206 304→216, 2305v1 94→38, redp5110 301→269,
amt_handbook 16→4.

Key changes

  • docling-parse line sanitizer (dp_lines.rs, a port of cells.h) — the default
    text path: 3-pass corner-distance contraction, ligature recomposition, loose-box
    geometry. Closed the text-run-boundary ceiling (got multi_page exact).
  • TableFormer — image encoder + autoregressive OTSL decoder + cell-bbox decoder
    ported to ONNX, cv2-exact preprocessing; reproduces docling's padded GitHub tables.
  • Signed-gap space insertion — fixed pervasive pdfium f-overhang spurious spaces
    (Sel f, specif y).
  • Markdown escaping (\_, <), @-glue (mAP @0.5),
    paragraph-continuation merge across column/page breaks, zero-width space-box
    reconstruction
    , band-aware two-column reading order, U+2212/U+2044 normalization.
  • Padded tables — all backends emit live docling's padded GitHub tables; the PDF
    groundtruth was regenerated to match.

Remaining gaps are model-level: TableFormer structure on complex multi-row headers,
layout classification (TOC↔picture, survey↔table), title-page reading order, RTL
justified doubles. See PDF_CONFORMANCE.md.

Performance (README)

docling (Python) vs fleischwolf (Rust), largest fixture per type: 41–66× less peak
memory
on declarative formats (2.2× on the full PDF ML pipeline), 377–1190× faster
end-to-end
(docling re-pays torch import per run).

Tests

  • cargo test --workspace green (unit + 131-source declarative regression).
  • scripts/pdf_conformance.sh snapshot baseline regenerated and clean; directory
    renamed tests/pdf_snapshotstests/snapshots.

🤖 Generated with Claude Code

artiz and others added 4 commits June 28, 2026 11:44
Multi-column PDFs came out with mid-word splits and a stray control char.
pdfium emits the soft line-wrap hyphen as the U+0002 control char, and
region_text joined line cells with a space and left that char in place, so a
2-column paragraph rendered as "a concise and com[U+0002] pact repre[U+0002]
sentation" instead of "a concise and compact representation". This was the
dominant readability gap vs docling.

region_text (via the new clean_text helper) now:
- undoes soft-hyphen line wraps, dropping the U+0002/U+00AD control chars and
  merging the word -- matching docling ("compact", "end-toend");
- maps curly quotes and the ellipsis to ASCII, as docling does.
The PDF title is rendered as "##" (docling never emits a top-level "#" for
PDFs: 0 level-1 vs 119 level-2 headings across the groundtruth corpus).

Token spacing is otherwise left as a plain single-space join. docling's
per-glyph spacing comes from the PDF text stream (it keeps "{ ahn }",
"Name 1 .", "[ 9 ]"); a geometric gap heuristic and punctuation-tightening
were prototyped but diverged from docling more than the plain join, and a
centre-band sort that fixed citation ordering reordered prose elsewhere
(amt_handbook, RTL) -- so all were dropped after measuring.

Regenerated the PDF snapshot baseline (12 text fixtures: cleaner words, ASCII
quotes, "##" titles; pdf_conformance still 76/76 exact). 2203.01017v2.pdf vs
docling drops from 436 to 366 diff-lines -- the abstract and most prose now
match docling exactly. The residual gap is the geometric tables (no
TableFormer) and citation/footnote spacing that needs the PDF text stream
(documented in MIGRATION.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two docling-conformance fixes in the PDF assembly:

- Formula regions now emit `<!-- formula-not-decoded -->` (docling's standard
  pipeline does not decode formulas) instead of the garbled raw glyph text.
- Figure captions are paired with their picture and emitted *before* the image
  marker, matching docling (`Figure 1: …` then `<!-- image -->`). Previously the
  caption was emitted in its own lower reading-order slot, after the image.

Measured against the committed docling groundtruth, `picture_classification.pdf`
is now byte-for-byte exact, and the captioned/formula papers move closer
(2203.01017v2 366→346, amt_handbook 20→12, 2206.01062 330→321). pdf_conformance
stays 76/76 exact; 12 snapshot fixtures regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strict mode already diverges from docling for readability (underscore
un-escaping, code-fence languages). Extend it to tighten stray spaces around
punctuation: `[ 37 , 36 ]` → `[37, 36]`, `( x )` → `(x)`, `*c* .` → `*c*.`.
This cleans up both the PDF backend's glyph-split citation spacing and the space
the legacy emphasis serialization leaves before punctuation.

Default/legacy output is untouched (still byte-for-byte with docling, which
keeps those spaces). Only inline text nodes are affected — code blocks and table
cells are left alone. Regenerated the strict regression fixtures; added a unit
test pinning legacy-spacing vs strict-tightened.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PDF_CONFORMANCE.md records the current per-PDF byte-conformance vs the docling
groundtruth (1/14 exact after this PR) and scopes the remaining blockers:

- Text-stream extraction — why raw chars / GetBoundedText both fail, and that
  the real fix needs an FPDFText_GetText per-char-range binding that
  pdfium-render 0.8.37 doesn't expose (upstream/FFI work).
- TableFormer — weights, the autoregressive OTSL decoding loop, cell matching,
  span-aware serialization (six table PDFs blocked on it).
- RTL/bidi, duplicate glyphs, code fencing.

scripts/pdf_groundtruth.sh measures it (no docling install needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@artiz artiz changed the title fix(pdf): de-hyphenate wrapped words & clean up PDF text assembly PDF conformance: de-hyphenation, formula/caption, strict tightening + roadmap Jun 28, 2026
artiz and others added 25 commits June 28, 2026 18:19
…everted)

Updates the text-stream blocker with the full finding: FPDFText_GetText IS
reachable via the public PdfiumLibraryBindings trait (a second raw-FFI handle on
the same bytes drives GetText directly, no pdfium-render fork, stays
publishable), and it reads citations correctly in isolation. But none of the
eight reconstruction strategies tried (raw chars, gap-based, GetBoundedText,
real GetText with char-detected or segment-defined lines, ± U+FFFE
de-hyphenation, ± single-segment override) beats pdfium's native style segments
on the aggregate — the hard part is reconstructing docling's exact line and
character ranges (docling-parse territory), not the GetText call itself. Segment
extraction stays in production; --strict remains the citation-spacing stopgap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the style-segment text path with a glyph-level reconstruction that
mirrors docling's docling-parse, so default-mode spacing matches the groundtruth
much more closely. Driven through the raw PdfiumLibraryBindings FFI on a second
handle to the same bytes (FPDF_LoadMemDocument/LoadPage/FPDFText_LoadPage/
GetUnicode/GetCharBox) — pdfium-render's safe API only exposes whole style runs,
which lose inter-run spacing; no fork, stays publishable. Segments remain the
fallback when the raw text page can't be loaded.

The grouping, derived empirically from docling-parse's own word/line cells:
- words: a space glyph (pdfium emits them on most lines) pins the split; the
  fallback is a horizontal gap wider than ~0.25× the *line's* font height, so a
  small superscript citation isn't split at its tight digit gaps and a tracked
  display title isn't shattered (`Figures`, not `Fi gures`);
- lines: baseline drop *and* leftward x-reset (a descending comma can't fake a
  break; a rising superscript stays on its line);
- gluing: closing punctuation, opening punctuation, intra-number digits, and a
  period that runs into a digit/lowercase (`i.e.`, `98.5`) attach with no space.
Plus: em/en-dash → `-`, and list bullets are stripped (the serializer adds `- `).

Measured vs the docling groundtruth this beats the segment path on most PDFs
(2203 346→341, 2206 321→317, 2305v1-pg9 25→19, multi_page 76→54) and keeps
picture_classification byte-exact. pdf_conformance stays 76/76; 32 snapshot
fixtures regenerated. Citations like `[37, 36, 18, 20]` and `function add(a, b)`
now reconstruct correctly. Remaining gaps are table structure (TableFormer) and
source-dependent colon spacing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve decoder)

First milestone of the TableFormer port: scripts/export_tableformer.py loads
docling's TableModel04_rs (accurate) from the docling-ibm-models safetensors and
exports two ONNX graphs, both verified against PyTorch (max abs diff < 1.5e-5):

  encoder.onnx : image[1,3,448,448]      -> memory[784,1,512]
  decoder.onnx : tags[seq,1] + memory     -> logits[1,13], hidden[1,512]

Key findings that de-risk the rest of the port:
- The OTSL structure vocabulary is just 13 tokens (wordmap.json).
- The decoder runs with cache=None (re-embeds the full prefix each step), so it
  exports cleanly with a dynamic seq axis and is a plain Rust loop — no KV-cache.
- Needed `requires_grad_(False)` on the params for the decoder to trace.

The critical feasibility gate (ONNX-exporting an autoregressive transformer) is
passed. Remaining work is the Rust side — decode loop, OTSL→grid serialization
(port otsl.py), and cell-text matching — scoped in PDF_CONFORMANCE.md. The ONNX
weights are gitignored (exported/downloaded like the layout/OCR models).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…act vs docling

Builds the TableFormer inference in fleischwolf-pdf on the exported ONNX graphs:
preprocess a table crop (448², per-channel normalize), run encoder.onnx once,
then loop decoder.onnx feeding the growing token list back in, applying docling's
two structure corrections. Verified: the decode reproduces docling's OTSL token
sequence BYTE-EXACT on docling's own preprocessed table tensor (54 tokens on
2305v1-pg9).

Solving the autoregressive export took two non-obvious fixes, both recorded in
PDF_CONFORMANCE.md and the export script so they aren't re-hit:
- The model's decoder layer keeps only tgt[-1:] per layer and depends on a
  non-standard per-layer cache; a cache-less re-decode loses deep context (and
  the legacy ONNX tracer bakes the sequence length into MultiheadAttention's
  reshape). Fix: apply each layer to the whole prefix under a causal mask —
  mathematically identical to the cache, exports cleanly via the dynamo exporter
  with a symbolic seq axis, needs no KV-cache in Rust.
- docling runs docling-project/docling-models, not the also-cached
  ds4sd/docling-models; their TableFormer weights differ and give a different
  OTSL. Export from the former.
- docling's predict never increments line_num, so xcel→lcel applies on every row,
  not just the first.

Adds tableformer.rs (load/predict_otsl, ONNX gitignored, load() returns None when
absent so the geometric fallback stands), the tf_otsl example for inspecting a
PDF's predicted structure, and the corrected export script. Not yet wired into
assembly — remaining: preprocessing parity (the live crop differs from docling's
page→1024px→bbox→448 path), OTSL→grid with spans, and cell-text matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…→1024px→bbox→448)

The decode is byte-exact on docling's input tensor; the live pipeline must feed
the same pixels. docling resizes the whole page to 1024px height (cv2.INTER_AREA),
crops the table bbox out of that, then resizes the crop to 448² (bilinear). The
tf_otsl example now does the page→1024px (box-average) crop, and predict_otsl's
448 resize is bilinear, matching docling's two distinct interpolations.

This lands the correct method and confirms the crop geometry matches docling
(434×172px bbox at the same position on 2305v1-pg9). It does NOT yet reproduce
docling's OTSL live: the model is hyper-sensitive to exact pixels, and matching
docling's full raster pipeline byte-for-byte across different libraries (pdfium
render anti-aliasing, cv2 INTER_AREA, torchvision bilinear) is a separate, hard
parity problem — different filter choices swing the token count (54 target vs
88/121 observed). Recorded as the gating step in PDF_CONFORMANCE.md before
OTSL→grid, cell matching, and serialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ct vs docling

Reproduces docling's TableFormer preprocessing pipeline exactly, so the live Rust
inference now emits the SAME OTSL token sequence docling does (verified on
2305v1-pg9: 54 tokens, 6×8, identical; and all 5 tables of 2206.01062: matching
token counts). The model is hyper-sensitive to input pixels; four things had to
match byte-for-byte:

- Render: docling renders at 1.5× the target scale and downsamples (pypdfium2 →
  PIL BICUBIC). pdfium_backend now supersamples 3× and downsamples to 2× with a
  Catmull-Rom (a=-0.5) cubic — the same kernel. Cuts page-bitmap diff vs docling
  from 8.7% of pixels (max 185) to 2.0% (max 12).
- Page→1024px: cv2.INTER_AREA. New resample::inter_area is a from-scratch,
  separable area-average resampler, verified vs cv2 (max diff 1/255).
- Crop→448: cv2.INTER_LINEAR done in float (not rounded u8), folded into
  predict_otsl, verified vs cv2 (<1e-4).
- Tensor layout: docling transposes (2,1,0) → the model wants (C, W, H), not
  C,H,W. This transpose alone was scrambling rows/cols.

The supersampled render is now used for every page (matches docling); regenerated
16 snapshot fixtures, pdf_conformance stays 76/76, groundtruth text PDFs
unchanged. Adds the dump_stages example for render-parity debugging. Next:
OTSL→grid with spans, cell-text matching, serialization, pipeline wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ORT's multi-threaded float-reduction order varies across runs, flipping the CTC
argmax on low-confidence characters. This was latent until the sharper
supersampled render pushed a noisy fax (sample_with_rotation_mismatch) to
borderline confidence, making its snapshot non-deterministic (pdf_conformance
75/76 drift). Recognition inputs are tiny per-line crops, so single-threaded ORT
costs negligible throughput and restores determinism — the snapshot baseline is
unchanged (the committed output already matched the deterministic one).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… corpus

The committed groundtruth corpus for every format (HTML/CSV/LaTeX/PDF/…) uses
docling-core's older minimal table serializer — `| a | b |` cells, single-dash
`| - | - |` separators, no width padding — while render_table replicated current
docling-core's *padded* GitHub tables, diverging from the conformance reference
on every table-bearing fixture. Switch render_table to the compact format so the
library matches the committed fixtures byte-for-byte. This is a prerequisite for
the PDF TableFormer work to produce conformant tables, and it realigns
HTML/CSV/LaTeX tables with their groundtruth too.

Regenerated 120 `expected/` regression fixtures (regression green) and updated
the three inline backend table tests and MIGRATION.md §4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the export with the third graph, completing the model: the encoder now
also emits the raw enc_out [1,28,28,256], and bbox.onnx batches docling's
BBoxDecoder.inference over cells — enc_out + per-cell tag hidden states [N,512] →
boxes [N,4] (cxcywh, sigmoid) + classes. All three graphs verified against
PyTorch (boxes max diff 2e-7). This gives the per-cell geometry needed to match
PDF text cells onto the OTSL grid for cell content. The Rust OTSL decode is
unaffected (still byte-exact: 54 tokens on 2305v1-pg9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…formant

Three fixes that make code_and_formula byte-exact vs the groundtruth (2/14 now):

- Code blocks: extract a second, space-glyph-only grouping (`code_cells`) — the
  prose gap heuristic shatters monospace (`f un c t i o n`), but pdfium emits a
  real space glyph at every true gap, so honoring only those reproduces
  `function add(a, b) { return a + b; }`. Emit the region as a fenced block and
  tighten the punctuation spacing pdfium still inserts (`console .log` →
  `console.log`).
- Line splitting: a *large* baseline drop (≥1.5× line height) now always starts a
  new line, even without an x-reset. A centered page-number footer below a short
  last word ("…volutpat." + "1") was being merged into one tall cell that the
  region filter then dropped, losing the paragraph's last word.
- Code captions: pair a `Listing N:` caption with the code region below it and
  emit it *after* the block (docling's order), like figure captions but trailing.

Adds the dump_regions debug example; 20 snapshot fixtures regenerated (footers
now split onto their own lines, matching docling); clippy/tests clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
predict_table_structure runs the full model: encode → autoregressive decode
collecting per-cell decoder hidden states (docling's exact tag_H_buf /
bboxes_to_merge bookkeeping — skip-after-row-break, first-lcel of a horizontal
span) → bbox decoder → span-box merge (mergebboxes) → OTSL→grid with row/col
spans read off the grid. Verified on 2305v1-pg9: 41 cells, structure matches
docling exactly (# enc-layers rowspan 2, TEDs colspan 3) and the per-cell boxes
match docling's to ~2e-3 (cxcywh). Remaining: word-cell IoC matching → cell text
→ Table node + pipeline wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…4 conformant)

Wires TableFormer into the pipeline: per table region, crop (docling's
page→1024px box-average → bbox), predict structure (cells + boxes + spans), map
each cell box back to page points, match the page's word cells into cells by
intersection-over-word-area, and expand spans into the compact Markdown grid.
The 1370-line MatchingPostProcessor is skipped — verified unnecessary for clean
PDF tables (raw IoC matches already give the exact cell text).

2305v1-pg9 is now byte-exact, and every table PDF improves sharply (2206
318→210, redp5110 352→300, 2203 358→313, 2305v1 144→110). FULLY CONFORMANT:
3/14 (picture_classification, code_and_formula, 2305v1-pg9).

Supporting changes:
- Per-word cells (words_from_glyphs) for cell matching, with NO digit-digit glue
  (adjacent numeric columns must split: `0.965` `0.934`, not `0.9650.934`).
- Cell text keeps text-stream word order (not geometric), matching docling
  (`Inference time (secs)`, not `Inference (secs) time`).
- order_regions made generic so table grids stay aligned through the reorder.
- TableFormer loads only when its ONNX graphs are present; otherwise the
  geometric reconstruction stands (so this is gated on the exported models).

Regenerated 13 table-bearing snapshot fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Improves right-to-left (Arabic) extraction and tightens table-PDF assembly:

- RTL reading order: cells on a line read right→left, so an Arabic-majority
  region now sorts each line band by descending left edge (was always LTR).
- RTL line wrapping: line splitting is now direction-aware — RTL wraps reset x
  rightward (`g.l > p.r`), so wrapped Arabic paragraphs no longer merge into one
  overlapping cell.
- RTL word gaps: split at the right-to-left inter-word gap (`p.l - g.r`) rather
  than trusting pdfium's space glyphs, which it spuriously inserts inside Arabic
  words.
- Arabic lam-alef: pdfium decomposes the إ/أ/آ-lam ligatures in visual order
  (`alef, lam`); swap mid-word back to logical `lam, alef`. Restricted to the
  hamza/madda variants — plain `alef+lam` is ambiguous (article vs ligature).
- Arabic↔Latin boundary: insert the space pdfium runs together (`وPython` →
  `و Python`).
- Gap-aware cell join (all scripts): same-band cells join without a space when
  abutting (so a word split across segments isn't broken), with a space across a
  real gap or line break.

right_to_left_01 4→2, right_to_left_03 76→70, and the table papers tighten
(2206 210→200, redp5110 300→298, 2203 313→309). The 3 exact PDFs are unchanged.
No-op for non-Arabic text. Regenerated affected snapshot fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…conformance)

The compact `| - |` table serializer was applied globally, which matched the
committed PDF groundtruth corpus but diverged from current published docling
(padded GitHub tables) — regressing DOCX/HTML conformance (which scores against
live docling), e.g. docx 25→18 exact and every HTML table fixture.

Gate the format on a new `DoclingDocument::compact_tables` flag: the PDF backend
sets it (its groundtruth predates the padded serializer), while DOCX/HTML/CSV/…
keep the padded GitHub format to match live docling. `render_table` now takes a
`compact` parameter; the padded branch is restored verbatim from before the
compact change.

Restores docx 18→25/26 and the HTML table fixtures (table_01–05,
html_rich_table_cells, …) to EXACT; PDF stays compact (2305-pg9 table still
byte-exact vs its committed groundtruth, 3/14). Regenerated the 120 non-PDF
expected fixtures back to padded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a PDF row (3/14 byte-exact vs the committed groundtruth corpus, with a
footnote explaining why PDF is scored against the corpus rather than live
docling) and a paragraph describing the discriminative PDF pipeline (pdfium +
layout/TableFormer/PaddleOCR ONNX stack), what is byte-exact today, and the
text-extractor ceiling (pdfium vs docling-parse run boundaries; RTL bidi/ligature).
Refresh HTML to 28/33 (corpus grew by a large Wikipedia page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… tooling

Groundwork for porting docling-parse's text reconstruction (the path to higher
PDF conformance). Two pieces:

- Lam-alef ligature order: pdfium splits the Arabic lam-alef ligature into two
  chars at the *same* x in visual order (`alef-variant, lam`); docling-parse and
  logical order are `lam, alef-variant`. Reorder same-x ligature pairs at the
  glyph level. The shared-x test reliably distinguishes a real ligature from a
  genuine `alef + lam` sequence (article `ال`, `فعالة`) whose glyphs sit at
  different x — unlike the earlier ambiguous mid-word heuristic.
- Debug tooling: `pdfium_backend::debug_glyphs` + `examples/dump_chars` dump the
  raw pdfium char stream (codepoint + x) to diff against docling-parse's char
  cells; `pdfium_backend` is now `pub` for inspection.

Verified char-extraction parity against docling-parse: both emit chars in
content-stream order (visual for RTL), and with the ligature fix the lam-alef
order matches. The EXACT 3 stay exact; Arabic-only, no other regressions.

Next (phase 2): port `cells.h`'s 3-pass line contraction (corner-distance
adjacency + merge_with space insertion, LTR/RTL/LTR-reverse) to replace the
ad-hoc reconstruction — targets multi_page (run-boundary colons) and the RTL PDFs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port docling-parse's `create_line_cells` / `contract_cells_into_lines_v1`
(cells.h) to `dp_lines.rs`: the 3-pass contraction (LTR → RTL → LTR-reverse)
with corner-distance adjacency (`is_adjacent_to`), `merge_with` space insertion
(at most one space when the bottom-corner gap exceeds 0.33×avg-char-width, plus
literal space glyphs), ligature `eps_d1` relaxation, and `enforce_same_font`.
Char cells are built from pdfium glyphs using the *loose* box (uniform font
ascent/descent + advance, via the new `FPDFText_GetLooseCharBox`) so adjacent
glyphs share a top edge like docling-parse's `compute_rect`; font is the hash of
pdfium's `FPDFText_GetFontInfo` name+flags.

Validated on multi_page: the sanitizer reproduces docling-parse's line cells
cell-for-cell, including the font-driven split of a bold label from its regular
value (`IBM MT/ST (…Typewriter)` | `: Introduced in 1964, this `) that the
ad-hoc reconstruction merged.

Behind the `DOCLING_DP_LINES` flag (default off) — the legacy path and all 14
PDFs are unchanged (3/14). Glyph carries both the tight box (legacy) and loose
box+font (sanitizer); `debug_glyphs`/`dump_chars` now surface the font hash.

Next (phase 3): wire it into assembly — join adjacent line cells with a space
(docling joins line cells, where the legacy gap-aware join omits it) and have
clean_text preserve the sanitizer's spacing — then validate all 14 and default on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mbly (flag)

Under DOCLING_DP_LINES, join adjacent line cells with a single space (docling
joins its line cells; the legacy gap-aware join omitted it) and have clean_text
preserve the sanitizer's internal spacing instead of collapsing whitespace. Both
gated on the flag, so the default path is byte-identical (still 3/14).

With the flag, multi_page drops 54→22 — the run-boundary colons are now correct
(`Undo/Redo : Introduced`, `Typewriter) : Introduced`). amt_handbook 16→14
(justified double spaces preserved). picture_classification/code_and_formula stay
exact.

Two regressions remain in the dp path (to resolve before default-on):
- 2305-pg9 (0→6): text is interleaved word-by-word — region_text's band sort
  mis-orders the sanitizer's font-split fragments on a dense two-column page.
- right_to_left_01 (2→4): RTL assembly interaction.
multi_page's remaining 22 are a separate ordered-list rendering issue
(`- 1. Undo/Redo` vs `1. Undo/Redo`), not a sanitizer problem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two pdfium glyph quirks broke the sanitizer's merging:
- Generated space glyphs carry a default font that blocked every word↔space
  merge under enforce_same_font. Spaces are now font-neutral (0), and the font
  check treats 0 as a wildcard.
- pdfium gives generated spaces a degenerate *zero-width* loose box at the wrong
  baseline, so corner-distance adjacency failed and lines fragmented into words.
  Drop only zero-width spaces (the inter-word gap then drives merge_with's space
  insertion); spaces with real width are kept so justified double-spaces survive.

Net effect with DOCLING_DP_LINES on: the 2305-pg9 word-interleaving is gone, and
general text improves — multi_page 54→22, normal_4pages 108→82, redp5110
300→256, amt_handbook 16→14. Default path (flag off) unchanged (EXACT 3 intact).

Blocker before default-on: pdfium decomposes fi/ffi ligatures into separate
glyphs whose gaps trip the inserted-space rule (`conf iguration`, `di f f i cult`),
regressing 2305-pg9 to 4. docling-parse keeps the ligature as one cell — next fix
is to recompose them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…igatures

pdfium decomposes a single ligature glyph (Latin fi/ffi, Arabic lam-alef) into
several chars at the *same* loose box. The contraction's gap is a Euclidean
distance (always positive), so the zero-width overlap read as a real gap and
inserted spurious intra-word spaces (`conf iguration`, `di f f i cult`). Recompose
them: consecutive glyphs sharing a loose box are appended into one cell, exactly
like docling-parse keeps the ligature whole.

This recovers 2305-pg9 to EXACT (was 0→4) and RTL_01 to 2, making the dp path
net-positive with no regression vs legacy: 2203 309→285, 2206 200→168,
multi_page 54→22, normal_4pages 108→82, redp5110 300→252, amt 16→14,
table_mislabeled 113→111; the 3 EXACT PDFs stay exact. Still behind the flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dp path

- Ordered list items: detect an `N.` enumeration marker at the start of a
  list item and render it as an ordered item (`N. text`) instead of `- N. text`.
  This is un-gated, helping the whole corpus (2305v1 114→64, 2203 285→263,
  table_mislabeled 111→102, redp5110 252→243).
- Wrap dehyphenation (dp path): a line cell ending in a hyphen/dash followed by a
  lowercase continuation joins without the dash or a space (`platforms—` +
  `reflects` → `platformsreflects`). The dash is still raw at join time
  (clean_text normalizes em/en dashes later), so match -, ‐, –, — all.

With DOCLING_DP_LINES: multi_page 54→**EXACT**, taking the dp path to **4/14**
(vs legacy 3/14). Default path unchanged. Debug dumps now show loose box + font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the prose reconstruction to dp_lines (the docling-parse sanitizer port) by
default; `DOCLING_LEGACY_LINES` restores the old gap-heuristic path. Routed
through a single `use_dp_lines()` helper (pdfium_backend), used by page_cells and
by assemble's cell-join + clean_text branches.

PDF conformance default 3/14 → **4/14** (multi_page now byte-exact), with the rest
markedly closer: 2206 200→168, redp5110 300→243, 2305v1 110→64, normal_4pages
108→82, 2203 309→263, table_mislabeled 113→102. The 3 previously-exact PDFs stay
exact. DOCX/HTML/etc. are unaffected (the sanitizer is PDF-only). Updated the
clean_text unit test for the preserve-spacing default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
multi_page is now byte-exact and the corpus is markedly closer. Update the PDF
row (4/14, within-one 5/14) and describe the dp_lines sanitizer port that closed
the text-run-boundary gap; note the remaining gaps (RTL justified double-spaces,
table reading-order).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PDF tables were compact to match the stale committed groundtruth, but
conformance.sh (and DOCX/HTML) score against live published docling, which uses
padded GitHub tables — so the compact rows showed as cosmetic diffs (e.g.
2305-pg9 = 14 vs live). The PDF backend no longer opts into compact tables, so it
emits the padded format like every other backend.

Regenerated the committed PDF groundtruth from live docling (the conformance
reference) via scripts/docling_convert.py, so the fixtures and conformance.sh
agree. Result vs live docling: 2305-pg9 → EXACT (4/14), and the table papers drop
sharply (2203 321→275, 2206 304→276, 2305v1 94→68) now that the table format
matches. multi_page/code_and_formula/picture_classification stay exact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full-width regions (title, wide figures/tables spanning most of the page) now
break the two-column flow into horizontal bands: within a band the left column
reads fully then the right, and a full-width region reads after the band above it
and before the band below. Previously a full-width title was assigned to a column
by its center and emitted in the middle of the page (the DocLayNet title landed
12th instead of first).

Band break requires spanning >70% of the width (a merely wide column region
doesn't count), which keeps it from mis-ordering dense layouts. Net −12 diff-lines
(normal_4pages 86→78, right_to_left_03 70→66, 2206 276→274); the 4 exact PDFs are
unchanged. The table papers remain dominated by finer reading-order + table-content
differences.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
artiz and others added 12 commits June 29, 2026 16:22
docling joins a paragraph whose previous fragment ends mid-sentence (a letter,
not sentence punctuation) with a lowercase continuation (`…definition of` +
`lists in…` → `…definition of lists in…`); my pipeline emitted two paragraphs.
Add a post-assembly pass that merges consecutive top-level paragraph nodes on that
rule. A heading/table/figure between fragments ends the paragraph.

2203 277→255, 2305v1 68→56, with smaller gains on 2206/redp5110/normal_4pages;
the 4 exact PDFs are unchanged (their paragraphs end with sentence punctuation, so
nothing false-merges).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pdfium gives generated spaces a zero-width loose box at a wrong baseline. Dropping
them (to fix the 2305-pg9 jumble) also dropped real word-separator spaces, merging
words (`Information systems` → `Informationsystems`, `high-quality` → `highquality`).
Now reconstruct a degenerate space by spanning the gap to the next glyph on the
same line, so the sanitizer keeps it as a separator. A wrap (different baseline) or
a touching gap is left alone.

2206 272→264, 2203 255→253, 2305v1 56→54, normal_4pages 76→74; the 4 exact PDFs
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…alizer)

docling-core's markdown serializer escapes `_` → `\_` then HTML-escapes `&`/`<`/`>`
(post_process). The other backends escape at the backend level; the PDF backend
didn't. Add md_escape to PDF prose nodes (headings, list items, paragraphs) — code
blocks, the formula placeholder, and table cells stay raw, and strict mode still
unescapes for the cleaner variant.

redp5110 299→271, 2305v1 54→44, 2203 253→247, table_mislabeled 116→110 (net ~−50);
the 4 exact PDFs are unchanged (no special chars in their prose).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gments

- Map U+2212 (minus sign) to ASCII `-` in clean_text alongside the en/em dashes
  (author-ID `[0000 −0002]` → `[0000 -0002]`).
- merge_continuations now also joins a fragment ending in a wrap hyphen/dash with
  a lowercase continuation (`vocab-` + `ulary` → `vocab- ulary`), matching docling.

2305v1 44→40; the 4 exact PDFs unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…decorators)

`@` now binds to what follows in the word/line grouping, so a table cell reads
`mAP @0.5-0.95` instead of `mAP @ 0.5-0.95`. The mismatched cell width cascaded
through the padded columns, so fixing it dropped 2206 264→234. Emails and
decorators benefit too. The 4 exact PDFs (incl. code_and_formula) are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…docling

Rewrite PDF_CONFORMANCE.md (was 1/14, listing text-extraction + TableFormer as
not-done — both shipped): current 4/14 byte-exact vs live docling, the
docling-parse line-sanitizer port, the text/serializer fixes, and the remaining
model-level blockers (TableFormer structure, layout classification, reading order,
RTL doubles). Fix MIGRATION.md's stale claims (geometric tables → TableFormer;
segment-join → dp_lines sanitizer; compact → padded tables; snapshot-only →
byte-for-byte vs live docling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pdfium's loose box for overhanging glyphs (`f`, etc.) extends left and overlaps the
previous glyph. The line sanitizer measured the inter-cell gap as the Euclidean
corner distance, which reads a -1.45 overlap as a +1.45 gap > delta and inserts a
spurious space (`Self` → `Sel f`, `specify` → `specif y`, `Page-footer` →
`Page-f ooter`). Use the signed horizontal gap (`other.left - self.right`) for the
space test: an overlap is negative → no space; a real justified gap is positive →
space. Adjacency still uses the Euclidean distance. Also map U+2044 (⁄) → `/`.

amt_handbook 14→6, 2203 247→211, 2206 234→216, with smaller gains elsewhere; the 4
exact PDFs unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A justified line's trailing space glyph at a wrap is a standalone whitespace cell;
trimmed it is empty, but the join inserts a separator before and after it, doubling
the space (`all-metal  construction`). Skip empty cells so the wrap joins with one
space, leaving genuine within-cell justified doubles intact.

amt_handbook 6→4; no other corpus changes; the 4 exact PDFs unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benchmark the largest fixture of each supported type (PDF, DOCX, HTML, JATS, XLSX,
PPTX, Markdown, CSV) with scripts/performance.sh: peak-memory ratio, CPU ratio, and
warm-conversion speedup, docling (PyPI) vs the Rust release binary. Peak memory is
the decisive win (41–66× less on declarative formats, 2.2× on the full PDF ML
pipeline); end-to-end is 377–1190× faster for declarative formats where docling
re-pays torch import startup every run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PDF/image snapshot baseline drifted from this session's text-layer fixes
(markdown escaping, signed-gap space insertion, @-glue, paragraph-continuation
merge, space-box reconstruction). Regenerate it so pdf_conformance.sh is clean
again; the diffs are the intended improvements (43 of 76 snapshots updated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The snapshot baseline covers every format the PDF/image pipeline emits output for
(pdf, scanned, tiff, webp, odf, latex/html-embedded images, mets…), not just PDF,
so the directory name was misleading. Rename it and update pdf_conformance.sh's
references. The snapshot example takes the output dir as an argument, so nothing
else changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PDF groundtruth is regenerated from live docling (padded tables), so the stale
"compact table / predates padded serializer" footnote is wrong. Update it, and
describe the remaining gaps as model-level (TableFormer structure, layout
classification, reading order, RTL doubles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@artiz artiz changed the title PDF conformance: de-hyphenation, formula/caption, strict tightening + roadmap PDF pipeline: docling-parse line sanitizer + TableFormer + conformance push (4/14 exact) Jun 29, 2026
The earlier bulk regen timed out before reaching tests/snapshots/tiff; its baseline
was stale (pre escaping/dehyph/reading-order). Refresh it — the diff is the same
intended improvements (title to reading-order top, wrap dehyphenation, `&`→`&amp;`).
Snapshot conformance is now 91/91 exact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@artiz
artiz merged commit 7e6f6d4 into master Jun 29, 2026
3 checks passed
@artiz
artiz deleted the bugfix/pdf-parsing branch June 29, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant