Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
92e807e
feat(pdf): port docling's LayoutPostprocessor for #61 classification
artiz Jul 10, 2026
ef19e3e
fix(pdf): glue `@` to the following token in table cells
artiz Jul 10, 2026
42ffd7b
style(pdf): rustfmt the #61 layout postprocessor code
artiz Jul 10, 2026
e6a9540
feat(pdf): port docling's rule-based reading-order predictor
artiz Jul 10, 2026
a8a7d6a
feat(pdf): port docling's reading-order text merges (predict_merges)
artiz Jul 10, 2026
5c21c04
fix(pdf): normalize the bullet glyph `•` to `·`
artiz Jul 10, 2026
f6da160
test(dclx): geometry-tolerant PDF conformance comparator
artiz Jul 11, 2026
80974f5
feat(pdf): emit layout provenance in DocLang (.dclx)
artiz Jul 11, 2026
02babc4
test(dclx): add PDF DocLang groundtruth for conformance
artiz Jul 11, 2026
96a5e62
docs(pdf): document DocLang (.dclx) conformance + geometry tolerance
artiz Jul 11, 2026
7d356a8
docs(migration): record PDF .dclx conformance (62% at +/-2)
artiz Jul 11, 2026
62c1fd1
feat(pdf): list-item, code, and furniture provenance in DocLang
artiz Jul 11, 2026
5c88812
docs: refresh conformance numbers against the issue-61 base
artiz Jul 11, 2026
4f48f2a
docs(pdf): resolve #63 amt double-space as by-design
artiz Jul 11, 2026
6713a1e
fix(pdf): dehyphenate number ranges wrapped at their hyphen (#62)
artiz Jul 11, 2026
3d76243
feat(pdf): port docling's TableFormer matching post-processor (#60)
claude Jul 13, 2026
353e986
fix(pdf): stop reading-order sorts panicking on docling's fuzzy compa…
claude Jul 13, 2026
8b2305b
docs(pdf): #60 matching-postprocessor state + refreshed table numbers
claude Jul 13, 2026
0bebe13
fix(pdf): normalize every curly quote to ' per docling-parse's saniti…
claude Jul 13, 2026
88defb4
fix(pdf): join region cells in docling-parse index order, not band or…
claude Jul 13, 2026
84c1e3a
test(pdf): refresh snapshots for the #62 quote + cell-order fixes
claude Jul 13, 2026
bd2d274
docs(pdf): record the #62 quote/cell-order fixes + refreshed numbers
artiz Jul 13, 2026
4ef12aa
style(pdf): rustfmt the dash-family replace array
artiz Jul 13, 2026
ff7db3c
chore(pdf): drop one-off debug examples dump_bbox and probe_page
artiz Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ phased plan is kept at the end as history.)
> against *live* docling; the PDF/image/METS ML path lives in `docling-pdf`
> (a pure-Rust PDF text parser + pdfium rasterization + ONNX
> layout/TableFormer/OCR + a port of docling-parse's line sanitizer) and is also
> measured byte-for-byte against live docling — **6 / 16 PDF fixtures exact, 7 / 16
> measured byte-for-byte against live docling — **5 / 14 PDF fixtures exact, 6 / 14
> whitespace-normalized** (see `PDF_CONFORMANCE.md`), with a snapshot baseline
> guarding against regressions. `cargo test` is green (unit tests + a 133-source
> output-regression suite).
Expand All @@ -24,7 +24,7 @@ phased plan is kept at the end as history.)
| | |
|---|---|
| **What** | A Rust port of docling's converter, backends, and discriminative PDF/ASR pipelines; same `convert → DoclingDocument → export_to_markdown()/json()` shape, single static binary, no Python/torch at runtime |
| **Conformance** | Declarative formats byte-for-byte vs *live* PyPI docling (most 100%, see §2); `.dclx` DocLang output ≈91% mean vs docling's own `.dclx` (§2); PDF ML path 6/16 fixtures byte-exact, rest close; every optimization is gated on this not regressing |
| **Conformance** | Declarative formats byte-for-byte vs *live* PyPI docling (most 100%, see §2); `.dclx` DocLang output ≈91% mean vs docling's own `.dclx` (§2); PDF ML path 5/14 fixtures byte-exact, rest close; every optimization is gated on this not regressing |
| **Performance** | PDF ML pipeline **4.3× faster warm / 4.7× end-to-end** than Python docling at 2.3–2.6× less peak RAM (INT8 + SIMD, conformance-validated); declarative formats 20–60× warm, ~60× less RAM; details + methodology in [`PDF_PERFORMANCE.md`](./PDF_PERFORMANCE.md) |
| **Models** | docling's own checkpoints (layout heron, TableFormer, PP-OCRv3, Whisper tiny), format-converted to ONNX by `scripts/export_*.py` — no retraining; INT8 variants are calibrated post-training quantizations (`scripts/install/quantize_models.py`) |
| **Tracking upstream** | See [§9](#9-keeping-up-with-upstream-docling): conformance is measured against the *latest published* docling on demand, so an upstream release that changes output surfaces as a concrete per-fixture diff |
Expand Down Expand Up @@ -106,7 +106,7 @@ content-type resolution, and image extraction — reused by DOCX/PPTX/XLSX/EPUB.

These run docling's *discriminative* PDF pipeline ported to ONNX. They are now
measured **byte-for-byte against live docling** (the committed PDF groundtruth is
regenerated from it): **6 / 16 exact (7 / 16 whitespace-normalized)**, the rest
regenerated from it): **5 / 14 exact (6 / 14 whitespace-normalized)**, the rest
close — see `PDF_CONFORMANCE.md`. A deterministic snapshot baseline
(`scripts/conformance/pdf_conformance.sh`) still guards against regressions.

Expand All @@ -128,15 +128,18 @@ line-diffed, similarity `= 100·(1 − difflines / max_lines)`. **≈91% mean ov
|---|---|---|---|
| CSV / AsciiDoc / Email | **100%** | Markdown | 92% |
| USPTO | 98% | ODF / LaTeX | 91% |
| DOCX / PPTX | 96% | XLSX | 87% |
| DOCX / PPTX | 96% | XLSX | 85% |
| JATS | 95% | HTML | 84% |
| | | WebVTT | 81% |

The remaining format gaps are tracked under
[issue #32](https://github.com/docling-project/docling.rs/issues/32); its children
(#38–#41, #44) landed the ODF, USPTO legacy-entity, elife XML, wiki_duck and
APS-plain-text work — `pftaps` is now byte-exact (§5). PDF `.dclx` is byte-exact
on the one fixture with a groundtruth archive.
APS-plain-text work — `pftaps` is now byte-exact (§5). The PDF path now emits
full layout `<location>` provenance (text, headings, tables, pictures, list
items, code, and page-header/footer furniture), scored against a 16-fixture
DocLang groundtruth with a ±2-grid-unit geometry tolerance — **63% mean** (§3,
`PDF_CONFORMANCE.md`).

---

Expand All @@ -161,7 +164,11 @@ on the one fixture with a groundtruth archive.
xlsx/html/webvtt in the 80s (full table in §2). The format-by-format gaps are
tracked as [issue #32](https://github.com/docling-project/docling.rs/issues/32) and its
children (#38–#41, #44). This is an **output** format; a DocLang *input* backend is
still out of scope (§5).
still out of scope (§5). For **PDF**, where the reference `<location>` geometry
comes from docling's own layout run, the metric is scored with a ±2-grid-unit
geometry tolerance (text/structure still byte-exact): **52% exact · 63% at ±2**
(issue #32 target ≥50%); the remaining gap is model-level (TableFormer/layout/
reading order), not serialization — see [`PDF_CONFORMANCE.md`](./PDF_CONFORMANCE.md).

- **JSON** rebuilds docling's full `body`-tree-of-`$ref`s model from the `Node`
tree (texts/groups/tables/pictures, labels, list grouping, table grids,
Expand Down Expand Up @@ -233,7 +240,7 @@ These are deliberate or unavoidable divergences, not bugs.
dropped (Form-XObject text and glyph-name-only fonts were the two classes it
surfaced and fixed).
- Output is measured **byte-for-byte against live docling** (PDF_CONFORMANCE.md):
**6 / 16 exact, 7 / 16 whitespace-normalized**, the rest close. The remaining
**5 / 14 exact, 6 / 14 whitespace-normalized**, the rest close. The remaining
gaps are model-level (TableFormer structure on complex tables, layout
classification, title-page reading order) plus `amt`'s fraction spacing — a
docling quirk from its embedded-font OS/2 metrics that our single-spaced output
Expand Down
142 changes: 114 additions & 28 deletions PDF_CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,38 +13,90 @@ The groundtruth is regenerated from **live published docling**, so it agrees wit

## Current state

**6 / 16 strict** · **7 / 16 whitespace-normalized.**
**5 / 14 strict** · **6 / 14 whitespace-normalized.** (The two Korean
image-only pages `skipped_1page`/`skipped_2pages` carry no text groundtruth and
are no longer scored.)

| PDF | diff | dominant remaining blocker |
|---|---:|---|
| picture_classification | **exact** | — |
| code_and_formula | **exact** | — |
| multi_page | **exact** | — |
| 2305.03393v1-pg9 | **exact** | — (TableFormer table, cell-for-cell) |
| right_to_left_01 | **exact** | — (RTL period attachment) |
| right_to_left_02 | **exact** | — (kashida dedup + page-number layout) |
| amt_handbook_sample | 2 *(ws-ok)* | docling's spurious fraction double space — ours is more faithful |
| 2305.03393v1 | 32 | title-page reading order + author-ID run spacing |
| skipped_1page | 40 | image/diagram page (Korean); layout picture-detection |
| skipped_2pages | 44 | image/diagram pages (Korean); layout picture-detection |
| normal_4pages | 54 | reading order (heading numbering, footnote order) |
| right_to_left_03 | 66 | RTL bidi |
| table_mislabeled_as_picture | 108 | layout over-detects tables (survey rendered as tables) |
| 2206.01062 | 164 | TableFormer multi-row headers + title-page reading order |
| redp5110_sampled | 173 | TOC mis-classified as a picture; cover-page ordering |
| 2203.01017v2 | 183 | TableFormer structure + reading order |

`amt` is the 7th under the whitespace-normalized metric: its only diff is
| code_and_formula | 5 | code block reflowed to multiple lines + trailing newline |
| 2305.03393v1 | 26 | title-page reading order + author-ID run spacing |
| normal_4pages | 44 | reading order (heading numbering, footnote order) |
| right_to_left_03 | 60 | RTL bidi |
| table_mislabeled_as_picture | 86 | layout over-detects tables (survey rendered as tables) |
| 2206.01062 | 80 | TableFormer multi-row headers + title-page reading order |
| 2203.01017v2 | 130 | TableFormer structure + reading order |
| redp5110_sampled | 194 | TOC OTSL structure (model-level); cover-page ordering |

`amt` is the 6th under the whitespace-normalized metric: its only diff is
docling's spurious double space before the `1⁄4` fraction, where our single-spaced
output is the more faithful rendering. The remaining non-exact PDFs are heavy
multi-column / table docs whose gaps are model-level (TableFormer structure,
layout classification, title-page reading order), not text-layer.

The heavy table docs improved with the docling-parse **word-cell** grouping now
feeding TableFormer (2305.03393v1 93→32, 2203.01017v2 209→183, 2206.01062
198→164, redp5110 226→173): the parser's per-word cells reproduce docling-parse's
`word_cells` byte-for-byte, so cell-to-grid matching tracks docling more closely.
See "Text reconstruction" below.
The heavy table docs improved with the docling-parse **word-cell** grouping
feeding TableFormer and the #61 layout/reading-order postprocessor
(2305.03393v1 93→30, 2203.01017v2 209→161, 2206.01062 198→92): the parser's
per-word cells reproduce docling-parse's `word_cells` byte-for-byte, so
cell-to-grid matching tracks docling more closely. See "Text reconstruction"
below. The #60 matching work (docling's `MatchingPostProcessor` ported to
`tf_match.rs`, plus docling's exact table-crop rounding chain) took
2203 157→150 and redp5110 204→202 with every other fixture unchanged; the
#62 text fixes (docling-parse's quote-normalization table — every curly
quote → `'` — and joining region cells in docling-parse index order
instead of geometric bands) then took 2203 →130, 2206 92→80, 2305 28→26,
normal_4pages 56→44, redp5110 →194, and table_mislabeled 88→86.

## DocLang (`.dclx`) conformance

Separate from the Markdown metric above: how close `--to dclx` gets to docling's
DocLang archive, scored on the extracted `document.xml` against the committed
groundtruth (`tests/data/pdf/groundtruth_dclx/*.dclx`, from published docling
2.112.0). Run `scripts/conformance/dclx_conformance.sh pdf`; sweep the tolerance
with `scripts/conformance/dclx_pdf_tol_sweep.sh`.

**PDF avg similarity: 52 % exact · 63 % at the default ±2-grid-unit tolerance**
(issue #32 target: ≥50 %). The ±2 figure is within a point of the
*geometry-ignored* ceiling (65 %), so essentially all of the coordinate
difference is absorbed by ±2 — a wider tolerance buys almost nothing.

### What the geometry tolerance is, and why it is honest

Every laid-out block in a DocLang archive carries four `<location>` provenance
tokens — its bbox as `round(512·coord/page_dim)` on a 0–511 page grid
(docling_core's `_create_location_tokens_for_bbox`). We emit the same tokens
from our layout cluster boxes (`assemble.rs`, `norm_loc`) for text, headings,
tables, pictures, list items (on `ListItem.location`), code, and the
`page_header`/`page_footer` furniture blocks. Because our heron
layout model is docling's, the boxes agree to **~1 grid unit**; the small
residual is the aspect-ratio-stretch-vs-letterbox preprocessing difference, not a
structural gap. `dclx_diff.py` therefore counts a `<location>` pair as matching
when the two values are within `DCLX_TOL` (default **2**) grid units — **text,
tags, nesting, spans, and every non-geometry line stay byte-exact, and unmatched
lines always count against the score**. The tolerance is applied **only to PDF**,
where the reference geometry comes from docling's own layout run; formats whose
geometry is read from the same source file (OOXML slides/sheets) stay exact
(`DCLX_TOL=0`). `DCLX_TOL=0` reproduces a raw `diff` line-for-line.

### Per-fixture (±2)

Text/list-heavy pages land high (multi_page 82 %, right_to_left_02 82 %,
code_and_formula 81 %, 2305-pg9 78 %, right_to_left_01 75 %, amt 72 %,
normal_4pages 71 %, redp5110 65 %, 2206 61 %); the low ones are **model-level,
not provenance**: the big table papers (2203 51 %, 2305 52 %) diverge in
TableFormer cell structure (2203 alone is ~19 k table-grid diff lines),
table_mislabeled/picture_classification in layout classification, and
skipped_1/2page (Korean image pages) + right_to_left_03 in picture detection /
bidi — the *same* blockers that cap the Markdown metric. The corpus average is
bounded by these, so raising it further is a model problem, not a serialization
one: every laid-out block kind now carries provenance, so the ±2 figure sits at
the geometry-ignored ceiling.

## How the pipeline works

Expand Down Expand Up @@ -136,19 +188,51 @@ These yield smaller or uncertain gains than the text-layer work already shipped.
Each is tracked as its own issue:

1. **TableFormer structure on complex tables**
([#60](https://github.com/docling-project/docling.rs/issues/60)). Multi-row
headers / spans on the big papers (2206, 2203) differ from docling's OTSL
prediction; one cell-structure diff cascades through the padded columns into
many row diffs (2206's ~92 table-row diffs trace to ~4 structure diffs).
([#60](https://github.com/docling-project/docling.rs/issues/60)). The
*matching* half is done: docling's `MatchingPostProcessor` (cell-class-aware
good/bad IOU split, column-median snapping, adjacent-column de-duplication,
best-intersection word assignment, row/column-band orphan pickup) is ported
in `tf_match.rs` and is the default word→cell matcher, and the table crop
reproduces docling's exact rounding chain (`round(bbox) → ×2 → ×1024/h →
round`, banker's rounding) — 2203 157→150, redp5110 204→202, everything else
unchanged. The rest is **model-level**: the OTSL tag stream itself differs
from live docling on the hard crops (redp5110's TOC predicts `ched` where
docling gets `fcel`; multi-row headers / spans on 2206, 2203), so one
cell-structure diff still cascades through the padded columns into many row
diffs (2206's ~92 table-row diffs trace to ~4 structure diffs). A parity
harness (`DOCLING_RS_TF_MATCH_DUMP=dir` + `scripts/test/ref_match.py`-style
replay through docling's Python post-processor) confirmed the ported matcher
reproduces the reference on identical inputs, isolating the residual to the
model predictions. `DOCLING_RS_TF_SIMPLE_MATCH=1` reverts to the pre-port
best-overlap matcher.
2. **Layout classification**
([#61](https://github.com/docling-project/docling.rs/issues/61)). The layout
ONNX classifies redp5110's table-of-contents as a *picture* (docling renders
it as a table) and table_mislabeled's survey as *tables* (docling renders
lists/text) — opposite classifications, not a text problem.
([#61](https://github.com/docling-project/docling.rs/issues/61)) — *addressed
by porting docling's `LayoutPostprocessor`.* The raw RT-DETR detections now go
through the cleanup docling applies before assembly: per-label confidence
thresholds (`CONFIDENCE_THRESHOLDS`, stricter than the 0.3 base — a
picture/table/list needs ≥ 0.5), regular/picture/wrapper **bucketed** overlap
resolution (a high-score picture no longer suppresses a lower-score table or
table-of-contents index), the picture-vs-table cross-type rule
(`_handle_cross_type_overlaps`), and dropping a regular region absorbed by a
table/index/picture so it isn't emitted twice. With this, table_mislabeled's
survey is no longer over-detected as tables (108 → 88 vs groundtruth), and
redp5110's table-of-contents is now classified and rendered as a **table**
(`document_index`) instead of a picture. The TOC table's remaining diff is a
TableFormer dot-leader column-matching gap, tracked with the other
table-structure work in
[#60](https://github.com/docling-project/docling.rs/issues/60). *(The
groundtruth byte counts in the table above predate this change; regenerate the
committed snapshots with the `models-v1` models to refresh them.)*
3. **Complex title-page reading order**
([#62](https://github.com/docling-project/docling.rs/issues/62)). Author-block
/ abstract interleaving on the academic papers (band reading-order handles the
full-width title; the in-column author/abstract order is still off).
full-width title; the in-column author/abstract order is still off). Two
pieces landed: the suspected "TeX-font quote decode" gap turned out to be
docling-parse's *sanitizer* table (every curly quote → `'`; a `"` only ever
comes from a literal `quotedbl` glyph) — no font-program parsing needed —
and region cells now join in docling-parse index order (docling's
`_sort_cells`), which fixes off-baseline glyph drift like 2206's inline
math `>` landing on the wrong line.
4. **amt fraction double space (text-layer, strict-only)**
([#63](https://github.com/docling-project/docling.rs/issues/63)). docling boxes glyphs
with the embedded font's OS/2 typographic metrics, not the PDF descriptor's;
Expand All @@ -157,4 +241,6 @@ Each is tracked as its own issue:
(the whitespace-normalized metric credits it); reproducing docling's exact
spacing needs an embedded-font metrics layer, which globally entangles with RTL
box geometry (a trial that fixed one `¼` regressed `right_to_left_01`). See
`MIGRATION.md` §4.
`MIGRATION.md` §4. **Resolved as by-design:** our single space is the correct
rendering, so #63 is closed without matching docling's spurious extra space —
forcing a byte-match would degrade output and risk the RTL geometry.
50 changes: 48 additions & 2 deletions crates/docling-core/src/doclang.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,10 +529,32 @@ static IDENTITY_LABELS: &[&str] = &[
/// forces the block form (matching docling); the code text follows as a text
/// child (CDATA/plain glued to the closing tag, `<content>`-wrapped text on its
/// own line). Without a language, single-fragment text renders inline.
fn emit_code(out: &mut Out, depth: i32, language: Option<&str>, text: &str) {
fn emit_code(
out: &mut Out,
depth: i32,
language: Option<&str>,
text: &str,
location: Option<&[u16; 4]>,
) {
let label = language.and_then(code_lang_label);
let escaped = escape_text(text);
let is_content_element = escaped.starts_with("<content>");
// Layout provenance forces the block form: `<location>` tokens follow the
// opening `<code>`, before the (optional) label and the code body.
if let Some(loc) = location {
out.push(depth, "<code>".to_string());
push_location(out, depth + 1, loc);
if let Some(l) = label {
out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
}
if is_content_element {
out.push(depth + 1, escaped);
} else {
out.push_glue(escaped);
}
out.push(depth, "</code>".to_string());
return;
}
match (label, is_content_element) {
(None, false) => out.push(depth, format!("<code>{escaped}</code>")),
(None, true) => {
Expand Down Expand Up @@ -716,7 +738,26 @@ fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u
*i += 1;
}
Node::Code { language, text } => {
emit_code(out, depth, language.as_deref(), text);
emit_code(out, depth, language.as_deref(), text, None);
*i += 1;
}
Node::PageFurniture {
footer,
location,
text,
} => {
let tag = if *footer {
"page_footer"
} else {
"page_header"
};
out.push(depth, format!("<{tag}>"));
out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
push_location(out, depth + 1, location);
if !text.is_empty() {
out.push(depth + 1, escape_text(text));
}
out.push(depth, format!("</{tag}>"));
*i += 1;
}
Node::Table(t) => {
Expand Down Expand Up @@ -1289,7 +1330,12 @@ fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
t.location = Some(*location);
emit_table(out, depth, &t);
}
Node::Code { language, text } => {
emit_code(out, depth, language.as_deref(), text, Some(location));
}
// Other node kinds carry no location today — render them as-is.
// (Located list items are routed to emit_list by emit_nodes so they
// still group into one `<list>`.)
other => {
let mut i = 0usize;
emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
Expand Down
Loading
Loading