diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd1a3b0..5cc8e24 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -76,6 +76,14 @@ jobs:
run: cargo test --workspace
- name: Run tests (all features)
run: cargo test --workspace --all-features
+ - name: Rust OCR model integration
+ env:
+ MARKKIT_OCR_MODEL_DIR: ${{ github.workspace }}/target/ocr-models
+ RTEN_NUM_THREADS: '1'
+ RAYON_NUM_THREADS: '1'
+ run: |
+ python3 scripts/prepare_ocr_models.py
+ cargo test --release -p markkit --features pdf-ocr-ocrs --test pdf_ocr ocrs_recognizes_scanned_text_with_source_word_geometry -- --include-ignored
- name: Run tests (office formats, no HTTP client)
run: |
if cargo tree -p markkit -e normal | grep -E 'reqwest|hyper'; then
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index f11ef5c..b53d802 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -35,3 +35,5 @@ jobs:
run: |
pip install target/wheels/*.whl
python -c 'from importlib.metadata import metadata; import markkit; assert metadata("markkit")["Name"] == "markkit"; assert markkit.extract_html_sync("
Smoke
").strip() == "# Smoke"; print("Distribution and import OK")'
+ - name: Test structured documents and PDF layout
+ run: python -m unittest discover -s crates/markkit-python/tests -p 'test_*.py'
diff --git a/AGENTS.md b/AGENTS.md
index edeca4c..06a311c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -31,6 +31,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn
| formats/format-support | Formats, bytes-in API, detection order, DOCX + spreadsheet mapping |
| integrations/tool-contract | `MarkkitTool` LLM tool (`tool` feature), Everruns toolkit contract |
| security/security-model | One-page threat/invariant summary |
+| security/native-dependencies | Hayro/ocrs provenance, model licensing, native library and host-runtime inventory |
| security/fuzzing | cargo-fuzz targets, S17 oracle, nightly workflow, crash triage |
| security/threat-model | Threats T1-T9, mitigations, test map, resource limits, invariants S1-S21 |
| runtimes/python-package | PyO3 `markkit` module, maturin, PyPI |
@@ -81,6 +82,9 @@ just bench-criterion # Criterion micro-benches (crates/markkit/benches)
just eval-public-setup # Pinned Mira + isolated Python scorer environment
just eval-public-prepare # Download and verify public corpora (PR slice)
just eval-public # Release WCXB + AgentDocBench runs and regression gate
+just eval-pdf # Authored PDF text/layout/table expectations
+just ocr-models # Verify optional OCR model assets and attribution
+just eval-ocr # Tesseract/ocrs quality, geometry, timing and memory comparison
just pre-pr # Pre-PR checks
```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56a610a..0b98d58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,12 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### Fixed
+- PDF stream guards honor escaped dictionary keys and bound compressed prefixes
+ before image filters, including when OCR is disabled.
+
- Footnote bodies cannot become unsafe reference-link destinations in Markdown readers
without footnote support. Bodies start after a blank line and remain valid footnotes
in readers that support them (nightly renderer fuzz regression).
### Added
+- Optional Rust OCR backend (`pdf-ocr-ocrs`) using ocrs/RTen and explicit local
+ ONNX model paths. CLI `--ocr-backend ocrs`, Node `pdfOcrBackend`, and Python
+ `pdf_ocr_backend` select it; Tesseract remains the default. Pinned model setup
+ and repeated scan comparisons report text quality, geometry, latency and memory.
+ **Breaking:** exhaustive Rust `MarkkitBuilder` literals also need
+ `pdf_ocr_backend`, `pdf_ocr_detection_model` and `pdf_ocr_recognition_model`.
+
+- PDF layout, reading order and table extraction: glyph-positioned words,
+ column reconstruction, ruled tables with merged cells, conservative borderless
+ tables and wrapped cells; bold/italic, numbered lists, tagged heading roles and
+ `ActualText`; optional repeated header/footer removal. Layout JSON includes
+ source runs/words and semantic blocks/lines/cells linked to IR provenance.
+- Opt-in native PDF OCR (`pdf-ocr`, CLI `--ocr`) with Rust Hayro rendering and installed Tesseract,
+ bounded raster size/output/deadline and configurable languages. WASM now supports
+ PDF text/layout/tables; Node and Python expose layout and native OCR options.
+ Python gains structured `extract_document_sync`.
+- `just eval-pdf`: 20 authored PDF scenes across default/layout modes, including
+ column, table, wrapped-cell, tagging and list expectations.
+ **Breaking:** exhaustive Rust `MarkkitBuilder` literals must include `pdf_layout`,
+ `pdf_remove_repeated_headers`, `pdf_ocr`, and `pdf_ocr_language`; builder methods
+ and `..Default::default()` remain valid.
+
- Public WCXB and AgentDocBench evaluations through Mira (`just eval-public`), with
pinned/verified corpora, a release-build baseline regression gate, per-type/format
quality and conversion timings. PR/push runs use a fixed slice; nightly runs cover
diff --git a/Cargo.lock b/Cargo.lock
index 237374f..6876796 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,21 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
[[package]]
name = "alloca"
version = "0.4.0"
@@ -88,6 +103,12 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
[[package]]
name = "async-trait"
version = "0.1.92"
@@ -199,18 +220,65 @@ dependencies = [
"syn 3.0.9",
]
+[[package]]
+name = "brotli"
+version = "8.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
[[package]]
name = "bytes"
version = "1.12.1"
@@ -371,6 +439,15 @@ dependencies = [
"encoding_rs",
]
+[[package]]
+name = "color"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
+dependencies = [
+ "bytemuck",
+]
+
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -511,7 +588,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
- "phf",
+ "phf 0.14.0",
"smallvec",
]
@@ -638,6 +715,15 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "fast-float2"
version = "0.2.4"
@@ -650,6 +736,27 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "fearless_simd"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4beca3cb2444e3304ac30843cc091f44ed58932353cd492ce740067bfce6b12"
+
+[[package]]
+name = "fearless_simd"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "629c9d6b6f30b5604089e0980f4d924a013aacb1654a6cdf5d3d852224f68651"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
@@ -662,6 +769,8 @@ version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
"zlib-rs",
]
@@ -671,6 +780,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "font-types"
+version = "0.12.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
+dependencies = [
+ "bytemuck",
+]
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -810,6 +934,32 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "glifo"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1218dedd5348c1b166ec3b8b3cbca49d2f527bd43b3fa072ea5123c523ed3f"
+dependencies = [
+ "bytemuck",
+ "foldhash",
+ "hashbrown",
+ "log",
+ "peniko",
+ "png",
+ "skrifa 0.44.0",
+ "smallvec",
+ "vello_common",
+]
+
+[[package]]
+name = "guillotiere"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
+dependencies = [
+ "euclid",
+]
+
[[package]]
name = "half"
version = "2.7.1"
@@ -826,6 +976,9 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "foldhash",
+]
[[package]]
name = "hashify"
@@ -839,18 +992,64 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "hayro"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4675ff5acd7ca41c0c34ccd2a3a154f5c86d8b5ec92bef0a114bfb194969e2af"
+dependencies = [
+ "bytemuck",
+ "fearless_simd 1.2.0",
+ "hayro-interpret",
+ "image",
+ "kurbo",
+ "pic-scale",
+ "rustc-hash",
+ "vello_cpu",
+]
+
[[package]]
name = "hayro-ccitt"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
+[[package]]
+name = "hayro-cmap"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
+dependencies = [
+ "brotli",
+ "hayro-postscript",
+]
+
+[[package]]
+name = "hayro-interpret"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
+dependencies = [
+ "bitflags",
+ "hayro-cmap",
+ "hayro-syntax",
+ "kurbo",
+ "moxcms",
+ "phf 0.13.1",
+ "rustc-hash",
+ "siphasher",
+ "skrifa 0.46.2",
+ "smallvec",
+ "yoke",
+]
+
[[package]]
name = "hayro-jbig2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
dependencies = [
+ "fearless_simd 1.2.0",
"hayro-ccitt",
]
@@ -859,6 +1058,15 @@ name = "hayro-jpeg2000"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
+dependencies = [
+ "fearless_simd 1.2.0",
+]
+
+[[package]]
+name = "hayro-postscript"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
@@ -866,8 +1074,11 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
dependencies = [
+ "flate2",
+ "hayro-ccitt",
"hayro-jbig2",
"hayro-jpeg2000",
+ "memchr",
"rustc-hash",
"smallvec",
"zune-jpeg",
@@ -879,6 +1090,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
[[package]]
name = "html5ever"
version = "0.40.1"
@@ -1091,6 +1308,19 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png",
+]
+
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -1207,6 +1437,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "kurbo"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "polycool",
+ "smallvec",
+]
+
[[package]]
name = "libc"
version = "0.2.191"
@@ -1223,6 +1465,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "linebender_resource_handle"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
+
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -1276,14 +1524,17 @@ dependencies = [
"criterion",
"ego-tree",
"futures-core",
+ "hayro",
"hayro-syntax",
"html5ever",
"mail-parser",
- "miniz_oxide",
+ "miniz_oxide 0.9.1",
+ "ocrs",
"proptest",
"pulldown-cmark",
"quick-xml",
"reqwest",
+ "rten",
"schemars",
"scraper",
"serde",
@@ -1351,6 +1602,7 @@ dependencies = [
"markkit",
"pyo3",
"pyo3-async-runtimes",
+ "serde_json",
]
[[package]]
@@ -1383,6 +1635,16 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1390,6 +1652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
+ "simd-adler32",
]
[[package]]
@@ -1430,6 +1693,16 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -1530,6 +1803,31 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "ocrs"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e4192a7dbc7570398ef97ab2606799703b6ea11da53f637d705d44546614435"
+dependencies = [
+ "anyhow",
+ "rayon",
+ "rten",
+ "rten-imageproc",
+ "rten-tensor",
+ "thiserror",
+ "wasm-bindgen",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1587,20 +1885,43 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "peniko"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
+dependencies = [
+ "bytemuck",
+ "color",
+ "kurbo",
+ "linebender_resource_handle",
+ "smallvec",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros 0.13.1",
+ "phf_shared 0.13.1",
+]
+
[[package]]
name = "phf"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
- "phf_macros",
- "phf_shared",
+ "phf_macros 0.14.0",
+ "phf_shared 0.14.0",
"serde",
]
@@ -1610,8 +1931,18 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
dependencies = [
- "phf_generator",
- "phf_shared",
+ "phf_generator 0.14.0",
+ "phf_shared 0.14.0",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.13.1",
]
[[package]]
@@ -1621,7 +1952,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
- "phf_shared",
+ "phf_shared 0.14.0",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
]
[[package]]
@@ -1630,13 +1974,22 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
- "phf_generator",
- "phf_shared",
+ "phf_generator 0.14.0",
+ "phf_shared 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "phf_shared"
version = "0.14.0"
@@ -1646,6 +1999,16 @@ dependencies = [
"siphasher",
]
+[[package]]
+name = "pic-scale"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1686,6 +2049,28 @@ dependencies = [
"plotters-backend",
]
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "polycool"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
+dependencies = [
+ "arrayvec",
+]
+
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -1756,6 +2141,12 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+
[[package]]
name = "pyo3"
version = "0.30.1"
@@ -1970,6 +2361,28 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "read-fonts"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
+dependencies = [
+ "bytemuck",
+ "font-types",
+ "once_cell",
+]
+
+[[package]]
+name = "read-fonts"
+version = "0.43.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
+dependencies = [
+ "bytemuck",
+ "font-types",
+ "once_cell",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2079,6 +2492,111 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rten"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf7741649977427aa13009537ac9a8fe1a503b0aa58784069ce8f117e47eb6ab"
+dependencies = [
+ "num_cpus",
+ "rayon",
+ "rten-base",
+ "rten-gemm",
+ "rten-onnx",
+ "rten-parallel",
+ "rten-shape-inference",
+ "rten-simd",
+ "rten-tensor",
+ "rten-vecmath",
+ "rustc-hash",
+ "smallvec",
+ "typeid",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "rten-base"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413dc975c7a3d0e6da0b146cd0387e3d389cceab0fc48328bfa716c7f2ae1b1e"
+
+[[package]]
+name = "rten-gemm"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8efc07c654d4b3f8d81e2efd8b13aea7475568395289e5bb35393411aa6a451e"
+dependencies = [
+ "rayon",
+ "rten-base",
+ "rten-parallel",
+ "rten-simd",
+ "rten-tensor",
+]
+
+[[package]]
+name = "rten-imageproc"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18726eae9614e23a400560eb3a5e3fdb06526914c31aadaf767f14afde7680dd"
+dependencies = [
+ "rten-tensor",
+]
+
+[[package]]
+name = "rten-onnx"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e"
+
+[[package]]
+name = "rten-parallel"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba9075b130f81df7be82496ea3a9bbf20fe9e75f106a22b0ece8700850f8f664"
+dependencies = [
+ "rayon",
+ "rten-base",
+]
+
+[[package]]
+name = "rten-shape-inference"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89289c26b6a5248fccf4d67627596848a03ed31888e6ce4ba6f666c689258122"
+dependencies = [
+ "rten-tensor",
+ "smallvec",
+]
+
+[[package]]
+name = "rten-simd"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f0c01294eb782adca256aa130b48a04006d03763073a68f45a9c33b982ea556"
+
+[[package]]
+name = "rten-tensor"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf72556ba231d7af50238d26dd264856a2149c6369694eb79b22644c8280c81b"
+dependencies = [
+ "rayon",
+ "rten-base",
+ "rten-parallel",
+ "smallvec",
+ "typeid",
+]
+
+[[package]]
+name = "rten-vecmath"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1007f05160f9ea2ce4e9c67324e5bafb27ae1e5302d0c6b1ea4db0d35fed78ee"
+dependencies = [
+ "rten-base",
+ "rten-simd",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -2427,6 +2945,26 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+[[package]]
+name = "skrifa"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
+dependencies = [
+ "bytemuck",
+ "read-fonts 0.41.0",
+]
+
+[[package]]
+name = "skrifa"
+version = "0.46.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
+dependencies = [
+ "bytemuck",
+ "read-fonts 0.43.3",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -2463,7 +3001,7 @@ checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
dependencies = [
"new_debug_unreachable",
"parking_lot",
- "phf_shared",
+ "phf_shared 0.14.0",
"precomputed-hash",
]
@@ -2473,8 +3011,8 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
dependencies = [
- "phf_generator",
- "phf_shared",
+ "phf_generator 0.14.0",
+ "phf_shared 0.14.0",
"proc-macro2",
"quote",
]
@@ -2762,6 +3300,12 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
[[package]]
name = "unarray"
version = "0.1.4"
@@ -2832,6 +3376,35 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "vello_common"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73c2fbe53548bcd25c8ff42f75a93770a8611a16b37bee56d7ef7b44e36027f1"
+dependencies = [
+ "bytemuck",
+ "fearless_simd 0.7.0",
+ "guillotiere",
+ "log",
+ "peniko",
+ "png",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "vello_cpu"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed1fec424fbba6acf6cbc78bb526447eb77590d95f40419d598870fe37535212"
+dependencies = [
+ "bytemuck",
+ "glifo",
+ "hashbrown",
+ "png",
+ "vello_common",
+]
+
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -2948,7 +3521,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
dependencies = [
- "phf",
+ "phf 0.14.0",
"phf_codegen",
"string_cache",
"string_cache_codegen",
diff --git a/Cargo.toml b/Cargo.toml
index d8cfd0e..8c47a25 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -52,7 +52,10 @@ calamine = "0.37" # Excel/spreadsheet (XLSX/XLS/ODS)
zip = { version = "9", default-features = false, features = ["deflate"] } # OOXML/ODF containers; matches calamine's zip
quick-xml = "0.42" # DOCX/ODS XML parsing
cfb = "0.15" # XLS (OLE2) container pre-scan
-hayro-syntax = { version = "0.8", default-features = false, features = ["std"] } # PDF object/content parsing (no image decoders)
+hayro-syntax = { version = "0.8", default-features = false, features = ["std"] } # PDF object/content parsing; OCR also enables image decoding
+hayro = { version = "0.8", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
+ocrs = { version = "0.13.1", default-features = false, features = ["onnx"] }
+rten = { version = "0.26", default-features = false, features = ["onnx_format"] }
miniz_oxide = "0.9" # Bounded FlateDecode for PDF streams
pulldown-cmark = { version = "0.13", default-features = false } # Markdown input; output-safety test oracle
mail-parser = { version = "0.11", default-features = false } # EML input (RFC 5322 / MIME)
diff --git a/README.md b/README.md
index 9602b1a..f92a7d6 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ package for browsers and edge runtimes.
- **Configurable** – CSS include/exclude selectors, link/image preservation, resource limits
- **Safe by default** – No code execution (scripts, macros, formulas), bounded decompression,
depth and size limits, [threat model](knowledge/security/threat-model.md) with a test per mitigation
-- **PDF** – Text layer to paragraphs, headings and bullet lists (pure Rust, no OCR; encrypted PDFs rejected)
+- **PDF** – Text/layout, columns and tables; optional native OCR (encrypted PDFs rejected)
| Format | Status | Feature |
|---|---|---|
@@ -47,9 +47,81 @@ package for browsers and edge runtimes.
| Jupyter notebooks (ipynb) | Supported | always on |
| Email (EML) | Supported | `email` |
| Markdown | Supported | `markdown` |
-| PDF (text layer) | Supported | `pdf` |
+| PDF (text, layout and tables) | Supported | `pdf` |
+| PDF OCR | Supported with Tesseract/language data | `pdf-ocr` |
| DOC, PPT | Detected, rejected explicitly | – |
+### PDF source layout
+
+```rust
+let parsed = markkit::Markkit::builder().pdf_layout(true).build()
+ .parse_document_sync(pdf_bytes, markkit::DocumentHint::file_name("report.pdf"))?;
+let layout = parsed.layout.as_ref().unwrap(); // pages, dimensions, positioned text spans
+```
+
+CLI: `markkit extract --file report.pdf --json --layout`.
+Node: `extractDocumentSync(bytes, { fileName: "report.pdf" }, { pdfLayout: true })`.
+WASM: `new Markkit({ pdfLayout: true }).extractDocument(bytes, { fileName: "report.pdf" })`.
+
+Layout v1 includes source runs and words, plus semantic blocks, lines and table
+cells in reading order. Coordinates are display points from the cropped/rotated
+page top-left (x right, y down). Source runs retain drawing order. Each block has
+an ID, type, bounds and source span IDs; table cells include row/column positions,
+rowspan/colspan and bounds. IR `Source.page` + `Source.layout_block` link blocks
+when layout is requested. Font bounds approximate an em box; OCR bounds come from
+the recognizer. Layout is omitted by default, and does not change Markdown.
+
+PDF extraction reconstructs columns, handles ruled and borderless tables, preserves
+bold/italic and numbered lists, and uses tagged-PDF heading roles and `ActualText`.
+Ambiguous alignments remain prose. Enable repeated margin-header/footer suppression
+with `.pdf_remove_repeated_headers(true)` or CLI `--remove-pdf-headers`.
+
+Native OCR uses Rust Hayro rendering for pages without text. Install **Tesseract**
+plus language data. Rust requires the `pdf-ocr` feature; the CLI,
+Node and Python packages compile support in. WASM extracts existing text layers;
+OCR helpers run in native hosts.
+
+```bash
+markkit extract --file scanned.pdf --ocr --ocr-language eng --timeout 300
+```
+
+Rust: `.pdf_ocr(true).pdf_ocr_language("eng+deu")`; Node: `{ pdfOcr: true,
+pdfOcrLanguage: "eng", timeout: 300 }`. Python: `extract_document_sync(data,
+file_name="report.pdf", pdf_layout=True, pdf_ocr=True, limits=Limits(timeout=300))`.
+The same structured Python API returns metadata and table counts for all formats.
+
+For Rust-only OCR, build with the optional `pdf-ocr-ocrs` feature and supply local
+ONNX models. Tesseract remains the default backend:
+
+```bash
+python3 scripts/prepare_ocr_models.py # verifies pinned model sizes and SHA-256
+cargo build --release -p markkit-cli --features pdf-ocr-ocrs
+target/release/markkit extract --file scanned.pdf --ocr --ocr-backend ocrs \
+ --ocr-detection-model target/ocr-models/text-detection.onnx \
+ --ocr-recognition-model target/ocr-models/text-recognition.onnx --timeout 300
+```
+
+Rust: `.pdf_ocr_backend(markkit::PdfOcrBackend::Ocrs)` plus
+`.pdf_ocr_detection_model(path)` and `.pdf_ocr_recognition_model(path)`.
+Extraction never downloads models. The pinned weights are CC-BY-SA 4.0, separate
+from the MIT/Apache library code; the setup script writes their attribution and
+license to `target/ocr-models/NOTICE.json`. The default model supports the Latin
+alphabet; `ocrs` currently accepts `eng` only. Models are trusted deployment
+assets. RTen inference checks deadlines between phases and batches; host quotas
+provide hard CPU/memory limits. `RTEN_NUM_THREADS` and `RAYON_NUM_THREADS` configure
+inference/postprocessing thread pools at process startup. Markkit's WASM binding
+does not yet expose this native OCR path.
+
+PDF geometry/rules count against `max_nodes`; retained geometry and analysis work
+are bounded by `max_pdf_decoded_size`. Table grids obey row/column/grid limits.
+Compact layout JSON and Markdown are independently bounded by `max_output_size`
+(excluding JSON wrapper/pretty-print overhead), including parse-only callers.
+Hayro checks image/raster budgets before rendering and the deadline before/after
+its non-interruptible render step. Tesseract uses private files, output caps and
+a killable deadline; no shell is used. OCR currently supports JPEG, JPEG2000 and
+generic-filter images without DecodeParms. Inline, JBIG2 and CCITT OCR is refused
+until bounded decoding is available. Host isolation provides hard CPU/memory quotas.
+
## Quick Start
### Rust Library
@@ -180,7 +252,7 @@ npm install @everruns/markkit-wasm
```
Any JS runtime (browsers, Cloudflare Workers, Deno, Bun, Node); no special headers.
-HTML, DOCX and spreadsheets; no URL fetching. See
+HTML, PDF text/layout, DOCX and spreadsheets; no URL fetching. See
[`crates/markkit-wasm/README.md`](crates/markkit-wasm/README.md).
## Configuration
@@ -206,7 +278,9 @@ let markkit = Markkit::builder()
| `docx` | No | DOCX conversion (zip + quick-xml) |
| `pptx` | No | PPTX conversion (zip + quick-xml) |
| `spreadsheet` | No | XLSX/XLSM, XLS, ODS conversion (calamine) |
-| `pdf` | No | PDF text-layer extraction (hayro-syntax, pure Rust) |
+| `pdf` | No | PDF text/layout, reading order and tables (pure Rust) |
+| `pdf-ocr` | No | Native OCR: Rust Hayro rendering + installed Tesseract/language data |
+| `pdf-ocr-ocrs` | No | Optional Rust OCR via ocrs/RTen and caller-supplied local ONNX models |
| `markdown` | No | Markdown normalization (pulldown-cmark) |
| `email` | No | EML conversion (mail-parser) |
| `odt` | No | ODT conversion (zip + quick-xml) |
@@ -238,7 +312,7 @@ names the field that was exceeded.
| `max_output_size` | 128 MiB | Markdown output |
| `timeout` | 60 s | Fetch + parse + render, per call |
| `max_nesting_depth` | 256 (hard max 512) | HTML flattens deeper content; deeper DOCX XML is rejected |
-| `max_nodes` | 5 000 000 | HTML DOM nodes, DOCX XML nodes |
+| `max_nodes` | 5 000 000 | HTML DOM nodes, DOCX XML nodes; retained PDF layout pages/runs |
| `max_archive_entries` | 10 000 | ZIP entries (DOCX/XLSX/ODS) |
| `max_archive_entry_size` | 64 MiB | Decompressed bytes per ZIP entry |
| `max_archive_total_size` | 256 MiB | Decompressed bytes per ZIP |
diff --git a/crates/markkit-bench/results/bench-pdf-layout-ship-1793241589.json b/crates/markkit-bench/results/bench-pdf-layout-ship-1793241589.json
new file mode 100644
index 0000000..5427ef0
--- /dev/null
+++ b/crates/markkit-bench/results/bench-pdf-layout-ship-1793241589.json
@@ -0,0 +1,522 @@
+{
+ "timestamp": 1793241589,
+ "system": {
+ "moniker": "pdf-layout-ship",
+ "hostname": "75f200628c94",
+ "os": "linux",
+ "arch": "x86_64",
+ "cpus": 4,
+ "markkit_version": "0.1.1",
+ "git_commit": "7a4e62f0497d",
+ "profile": "release"
+ },
+ "iterations": 20,
+ "warmup": 3,
+ "peak_rss_bytes": 36528128,
+ "results": [
+ {
+ "name": "html_fixture_article",
+ "category": "html",
+ "description": "Golden fixture full_article.html",
+ "iterations": 20,
+ "input_bytes": 881,
+ "output_bytes": 315,
+ "times_ns": [
+ 46813,
+ 45517,
+ 44921,
+ 45307,
+ 76701,
+ 54951,
+ 71166,
+ 58878,
+ 45694,
+ 46939,
+ 46516,
+ 46741,
+ 46165,
+ 46069,
+ 46055,
+ 46691,
+ 46043,
+ 45640,
+ 46513,
+ 46194
+ ],
+ "mean_ns": 49975.7,
+ "median_ns": 46353.5,
+ "stddev_ns": 8691.929072996394,
+ "min_ns": 44921,
+ "max_ns": 76701,
+ "ops_per_sec": 20009.724726216944,
+ "throughput_mib_s": 16.81191204433167,
+ "allocations": 158,
+ "allocated_bytes": 35167,
+ "peak_heap_bytes": 22099,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "html_blog_post",
+ "category": "html",
+ "description": "Article with nav/footer/scripts, 20 sections",
+ "iterations": 20,
+ "input_bytes": 15685,
+ "output_bytes": 12030,
+ "times_ns": [
+ 584145,
+ 560689,
+ 590203,
+ 615323,
+ 554728,
+ 572420,
+ 632011,
+ 587417,
+ 570463,
+ 555608,
+ 590713,
+ 557842,
+ 572445,
+ 638679,
+ 576898,
+ 687518,
+ 605808,
+ 645123,
+ 690913,
+ 562267
+ ],
+ "mean_ns": 597560.65,
+ "median_ns": 585781.0,
+ "stddev_ns": 40568.211151436044,
+ "min_ns": 554728,
+ "max_ns": 690913,
+ "ops_per_sec": 1673.470299625653,
+ "throughput_mib_s": 25.03240742647969,
+ "allocations": 2505,
+ "allocated_bytes": 345159,
+ "peak_heap_bytes": 247097,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "html_docs_page",
+ "category": "html",
+ "description": "Long docs page, 400 sections, no chrome",
+ "iterations": 20,
+ "input_bytes": 306808,
+ "output_bytes": 240691,
+ "times_ns": [
+ 11838625,
+ 11731597,
+ 11996133,
+ 11940130,
+ 11592701,
+ 12180716,
+ 12856740,
+ 12282815,
+ 13014072,
+ 11896949,
+ 12132685,
+ 11935568,
+ 12049239,
+ 12619906,
+ 13433711,
+ 16066508,
+ 11794425,
+ 12493820,
+ 12032603,
+ 12379253
+ ],
+ "mean_ns": 12413409.8,
+ "median_ns": 12090962.0,
+ "stddev_ns": 953061.4497135847,
+ "min_ns": 11592701,
+ "max_ns": 16066508,
+ "ops_per_sec": 80.55804296414995,
+ "throughput_mib_s": 23.570873304123797,
+ "allocations": 49342,
+ "allocated_bytes": 6969184,
+ "peak_heap_bytes": 4961744,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "html_tables",
+ "category": "html",
+ "description": "50 tables x 40 rows x 6 cols",
+ "iterations": 20,
+ "input_bytes": 262766,
+ "output_bytes": 174139,
+ "times_ns": [
+ 14735436,
+ 16687640,
+ 14065107,
+ 14533317,
+ 14416169,
+ 16439791,
+ 14082688,
+ 13832175,
+ 15854774,
+ 14867655,
+ 13669925,
+ 14364735,
+ 15349995,
+ 14755229,
+ 15297555,
+ 14154002,
+ 14512379,
+ 14644408,
+ 18767273,
+ 15121870
+ ],
+ "mean_ns": 15007606.15,
+ "median_ns": 14689922.0,
+ "stddev_ns": 1164190.9415011902,
+ "min_ns": 13669925,
+ "max_ns": 18767273,
+ "ops_per_sec": 66.63287868865082,
+ "throughput_mib_s": 16.697745324613592,
+ "allocations": 33635,
+ "allocated_bytes": 7111395,
+ "peak_heap_bytes": 5315858,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "html_boilerplate",
+ "category": "html",
+ "description": "Mostly script/style/nav to strip, little content",
+ "iterations": 20,
+ "input_bytes": 46312,
+ "output_bytes": 191,
+ "times_ns": [
+ 676915,
+ 663864,
+ 650786,
+ 716977,
+ 697255,
+ 666960,
+ 636731,
+ 662572,
+ 646993,
+ 656426,
+ 680715,
+ 638981,
+ 655176,
+ 664130,
+ 621318,
+ 638250,
+ 674455,
+ 608304,
+ 624358,
+ 663264
+ ],
+ "mean_ns": 657221.5,
+ "median_ns": 659499.0,
+ "stddev_ns": 25186.64614731386,
+ "min_ns": 608304,
+ "max_ns": 716977,
+ "ops_per_sec": 1521.5570397499168,
+ "throughput_mib_s": 67.20194780816855,
+ "allocations": 66,
+ "allocated_bytes": 264364,
+ "peak_heap_bytes": 257958,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "html_deep_nesting",
+ "category": "html",
+ "description": "Divs nested past the depth limit (1000 levels)",
+ "iterations": 20,
+ "input_bytes": 11011,
+ "output_bytes": 5,
+ "times_ns": [
+ 414513,
+ 424869,
+ 408592,
+ 415082,
+ 428873,
+ 415024,
+ 420195,
+ 404296,
+ 524581,
+ 429155,
+ 415840,
+ 434755,
+ 416972,
+ 451074,
+ 427698,
+ 433901,
+ 446099,
+ 424945,
+ 463847,
+ 393449
+ ],
+ "mean_ns": 429688.0,
+ "median_ns": 424907.0,
+ "stddev_ns": 26923.541089537237,
+ "min_ns": 393449,
+ "max_ns": 524581,
+ "ops_per_sec": 2327.270019176705,
+ "throughput_mib_s": 24.43844812503309,
+ "allocations": 551,
+ "allocated_bytes": 93075,
+ "peak_heap_bytes": 53331,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "docx_memo",
+ "category": "docx",
+ "description": "Short memo: heading, 10 paragraphs, 1 list",
+ "iterations": 20,
+ "input_bytes": 1429,
+ "output_bytes": 636,
+ "times_ns": [
+ 74549,
+ 74061,
+ 67981,
+ 66127,
+ 65961,
+ 64760,
+ 94431,
+ 67657,
+ 65758,
+ 66838,
+ 64828,
+ 65212,
+ 64832,
+ 64341,
+ 64532,
+ 64527,
+ 65138,
+ 64512,
+ 64358,
+ 64128
+ ],
+ "mean_ns": 67726.55,
+ "median_ns": 65175.0,
+ "stddev_ns": 6765.156963995734,
+ "min_ns": 64128,
+ "max_ns": 94431,
+ "ops_per_sec": 14765.258233292556,
+ "throughput_mib_s": 20.12210275208956,
+ "allocations": 487,
+ "allocated_bytes": 207703,
+ "peak_heap_bytes": 60705,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "docx_report",
+ "category": "docx",
+ "description": "Report: 400 sections with lists and tables",
+ "iterations": 20,
+ "input_bytes": 9299,
+ "output_bytes": 268569,
+ "times_ns": [
+ 16865670,
+ 17711098,
+ 16676757,
+ 16425247,
+ 17725293,
+ 18049829,
+ 17715314,
+ 17153265,
+ 18034253,
+ 17427472,
+ 18586530,
+ 16984482,
+ 16799437,
+ 18725013,
+ 17430906,
+ 16814605,
+ 18693276,
+ 16410731,
+ 16872208,
+ 18157874
+ ],
+ "mean_ns": 17462963.0,
+ "median_ns": 17429189.0,
+ "stddev_ns": 721536.3248924478,
+ "min_ns": 16410731,
+ "max_ns": 18725013,
+ "ops_per_sec": 57.26405077992778,
+ "throughput_mib_s": 0.5078300554299816,
+ "allocations": 136855,
+ "allocated_bytes": 12535866,
+ "peak_heap_bytes": 4096773,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "xlsx_small",
+ "category": "spreadsheet",
+ "description": "1 sheet, 50 rows x 5 cols",
+ "iterations": 20,
+ "input_bytes": 2865,
+ "output_bytes": 2723,
+ "times_ns": [
+ 313170,
+ 250837,
+ 246779,
+ 246161,
+ 266631,
+ 242644,
+ 245446,
+ 243012,
+ 268626,
+ 242709,
+ 242335,
+ 241710,
+ 264529,
+ 244240,
+ 241025,
+ 262054,
+ 266596,
+ 242300,
+ 241587,
+ 240093
+ ],
+ "mean_ns": 252624.2,
+ "median_ns": 244843.0,
+ "stddev_ns": 17000.194077127475,
+ "min_ns": 240093,
+ "max_ns": 313170,
+ "ops_per_sec": 3958.448953029836,
+ "throughput_mib_s": 10.815578699522476,
+ "allocations": 1165,
+ "allocated_bytes": 666307,
+ "peak_heap_bytes": 132231,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "xlsx_large",
+ "category": "spreadsheet",
+ "description": "3 sheets, 5000 rows x 10 cols each (150k cells)",
+ "iterations": 20,
+ "input_bytes": 784216,
+ "output_bytes": 1844036,
+ "times_ns": [
+ 131643926,
+ 131020753,
+ 135643173,
+ 132828999,
+ 140105438,
+ 131592771,
+ 128157132,
+ 136450013,
+ 132633156,
+ 127601147,
+ 127653114,
+ 130115468,
+ 129416760,
+ 131294725,
+ 128673412,
+ 131169409,
+ 132208996,
+ 128963111,
+ 131482277,
+ 131642690
+ ],
+ "mean_ns": 131514823.5,
+ "median_ns": 131388501.0,
+ "stddev_ns": 3012957.9636681704,
+ "min_ns": 127601147,
+ "max_ns": 140105438,
+ "ops_per_sec": 7.603705600532552,
+ "throughput_mib_s": 5.6867099678299295,
+ "allocations": 512786,
+ "allocated_bytes": 39383548,
+ "peak_heap_bytes": 22510370,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "ods_medium",
+ "category": "spreadsheet",
+ "description": "1 sheet, 2000 rows x 8 cols",
+ "iterations": 20,
+ "input_bytes": 71489,
+ "output_bytes": 166354,
+ "times_ns": [
+ 19708481,
+ 19426236,
+ 19350839,
+ 19756707,
+ 19792602,
+ 18707106,
+ 18796882,
+ 19939690,
+ 20128203,
+ 21413218,
+ 19285174,
+ 19979224,
+ 19820926,
+ 19490273,
+ 19663430,
+ 19214942,
+ 19423201,
+ 19930702,
+ 21699598,
+ 20581212
+ ],
+ "mean_ns": 19805432.3,
+ "median_ns": 19732594.0,
+ "stddev_ns": 721589.5909737127,
+ "min_ns": 18707106,
+ "max_ns": 21699598,
+ "ops_per_sec": 50.491197811420655,
+ "throughput_mib_s": 3.442349663105632,
+ "allocations": 72498,
+ "allocated_bytes": 9342742,
+ "peak_heap_bytes": 3180660,
+ "errors": 0,
+ "error_messages": []
+ },
+ {
+ "name": "detect_docx_magic",
+ "category": "detect",
+ "description": "Sniff DOCX from bytes without a hint",
+ "iterations": 20,
+ "input_bytes": 9299,
+ "output_bytes": 0,
+ "times_ns": [
+ 1831,
+ 1812,
+ 1740,
+ 1735,
+ 1738,
+ 1732,
+ 1732,
+ 1731,
+ 1729,
+ 1739,
+ 1741,
+ 1733,
+ 1726,
+ 1732,
+ 1725,
+ 1766,
+ 1729,
+ 1728,
+ 1730,
+ 1732
+ ],
+ "mean_ns": 1743.05,
+ "median_ns": 1732.0,
+ "stddev_ns": 27.639600214185442,
+ "min_ns": 1725,
+ "max_ns": 1831,
+ "ops_per_sec": 573707.0078311006,
+ "throughput_mib_s": 5087.75850851193,
+ "allocations": 0,
+ "allocated_bytes": 0,
+ "peak_heap_bytes": 0,
+ "errors": 0,
+ "error_messages": []
+ }
+ ]
+}
diff --git a/crates/markkit-bench/results/bench-pdf-layout-ship-1793241589.md b/crates/markkit-bench/results/bench-pdf-layout-ship-1793241589.md
new file mode 100644
index 0000000..5c6da26
--- /dev/null
+++ b/crates/markkit-bench/results/bench-pdf-layout-ship-1793241589.md
@@ -0,0 +1,50 @@
+# Markkit Benchmark Report
+
+## System Information
+
+- **Moniker**: `pdf-layout-ship`
+- **OS / Arch**: linux / x86_64
+- **CPUs**: 4
+- **markkit**: 0.1.1 (`7a4e62f0497d`)
+- **Profile**: release
+- **Timestamp**: 1793241589
+- **Iterations**: 20 (warmup 3)
+- **Peak RSS**: 34.84 MiB
+
+## Summary
+
+12 cases, 198.261 ms total mean time per pass, 0 errors.
+
+Allocations, allocated and peak heap are per single operation. Throughput is input bytes over mean time.
+
+### HTML
+
+| Benchmark | Input | Output | Mean | Median | StdDev | Ops/s | MiB/s | Allocs | Allocated | Peak heap | Errors |
+|-----------|------:|-------:|-----:|-------:|-------:|------:|------:|-------:|----------:|----------:|-------:|
+| html_fixture_article | 881 B | 315 B | 49.98 µs | 46.35 µs | ±8.69 µs | 20010 | 16.8 | 158 | 34.3 KiB | 21.6 KiB | - |
+| html_blog_post | 15.3 KiB | 11.7 KiB | 597.56 µs | 585.78 µs | ±40.57 µs | 1673 | 25.0 | 2505 | 337.1 KiB | 241.3 KiB | - |
+| html_docs_page | 299.6 KiB | 235.0 KiB | 12.41 ms | 12.09 ms | ±953.06 µs | 81 | 23.6 | 49342 | 6.65 MiB | 4.73 MiB | - |
+| html_tables | 256.6 KiB | 170.1 KiB | 15.01 ms | 14.69 ms | ±1.16 ms | 67 | 16.7 | 33635 | 6.78 MiB | 5.07 MiB | - |
+| html_boilerplate | 45.2 KiB | 191 B | 657.22 µs | 659.50 µs | ±25.19 µs | 1522 | 67.2 | 66 | 258.2 KiB | 251.9 KiB | - |
+| html_deep_nesting | 10.8 KiB | 5 B | 429.69 µs | 424.91 µs | ±26.92 µs | 2327 | 24.4 | 551 | 90.9 KiB | 52.1 KiB | - |
+
+### DOCX
+
+| Benchmark | Input | Output | Mean | Median | StdDev | Ops/s | MiB/s | Allocs | Allocated | Peak heap | Errors |
+|-----------|------:|-------:|-----:|-------:|-------:|------:|------:|-------:|----------:|----------:|-------:|
+| docx_memo | 1.4 KiB | 636 B | 67.73 µs | 65.17 µs | ±6.77 µs | 14765 | 20.1 | 487 | 202.8 KiB | 59.3 KiB | - |
+| docx_report | 9.1 KiB | 262.3 KiB | 17.46 ms | 17.43 ms | ±721.54 µs | 57 | 0.5 | 136855 | 11.96 MiB | 3.91 MiB | - |
+
+### Spreadsheet
+
+| Benchmark | Input | Output | Mean | Median | StdDev | Ops/s | MiB/s | Allocs | Allocated | Peak heap | Errors |
+|-----------|------:|-------:|-----:|-------:|-------:|------:|------:|-------:|----------:|----------:|-------:|
+| xlsx_small | 2.8 KiB | 2.7 KiB | 252.62 µs | 244.84 µs | ±17.00 µs | 3958 | 10.8 | 1165 | 650.7 KiB | 129.1 KiB | - |
+| xlsx_large | 765.8 KiB | 1.76 MiB | 131.51 ms | 131.39 ms | ±3.01 ms | 8 | 5.7 | 512786 | 37.56 MiB | 21.47 MiB | - |
+| ods_medium | 69.8 KiB | 162.5 KiB | 19.81 ms | 19.73 ms | ±721.59 µs | 50 | 3.4 | 72498 | 8.91 MiB | 3.03 MiB | - |
+
+### Detect
+
+| Benchmark | Input | Output | Mean | Median | StdDev | Ops/s | MiB/s | Allocs | Allocated | Peak heap | Errors |
+|-----------|------:|-------:|-----:|-------:|-------:|------:|------:|-------:|----------:|----------:|-------:|
+| detect_docx_magic | 9.1 KiB | 0 B | 1.74 µs | 1.73 µs | ±28 ns | 573707 | 5087.8 | 0 | 0 B | 0 B | - |
diff --git a/crates/markkit-cli/Cargo.toml b/crates/markkit-cli/Cargo.toml
index 9d07f64..ec5cfea 100644
--- a/crates/markkit-cli/Cargo.toml
+++ b/crates/markkit-cli/Cargo.toml
@@ -19,9 +19,11 @@ path = "src/main.rs"
doc = false
[features]
-default = ["all_formats"]
+default = ["all_formats", "pdf-ocr"]
all_formats = ["markkit/all_formats"]
pdf = ["markkit/pdf"]
+pdf-ocr = ["pdf", "markkit/pdf-ocr"]
+pdf-ocr-ocrs = ["pdf-ocr", "markkit/pdf-ocr-ocrs"]
docx = ["markkit/docx"]
pptx = ["markkit/pptx"]
spreadsheet = ["markkit/spreadsheet"]
diff --git a/crates/markkit-cli/src/main.rs b/crates/markkit-cli/src/main.rs
index 7f74cbc..3ec22ac 100644
--- a/crates/markkit-cli/src/main.rs
+++ b/crates/markkit-cli/src/main.rs
@@ -51,6 +51,29 @@ enum Commands {
#[arg(long)]
json: bool,
+ /// Include PDF page dimensions and positioned text runs in JSON output
+ #[arg(long, requires = "json")]
+ layout: bool,
+
+ /// OCR textless PDF pages using the selected local backend
+ #[arg(long)]
+ ocr: bool,
+ /// Installed Tesseract language codes, e.g. eng+deu
+ #[arg(long, requires = "ocr")]
+ ocr_language: Option,
+ /// OCR implementation: tesseract (default) or ocrs (optional feature)
+ #[arg(long, requires = "ocr", value_parser = ["tesseract", "ocrs"])]
+ ocr_backend: Option,
+ /// Local ONNX detection model for the ocrs backend
+ #[arg(long, requires = "ocr")]
+ ocr_detection_model: Option,
+ /// Local ONNX recognition model for the ocrs backend
+ #[arg(long, requires = "ocr")]
+ ocr_recognition_model: Option,
+ /// Suppress headers/footers repeated in PDF page margins
+ #[arg(long)]
+ remove_pdf_headers: bool,
+
/// Resolve relative links and images in files / stdin against this URL
/// (URLs use the final response URL)
#[arg(long)]
@@ -102,7 +125,7 @@ struct LimitArgs {
/// Maximum element nesting, hard-capped at 512 [default: 256]
#[arg(long, value_name = "N")]
max_nesting_depth: Option,
- /// Maximum parsed HTML DOM / DOCX XML nodes [default: 5000000]
+ /// Maximum parsed nodes / retained PDF layout pages and runs [default: 5000000]
#[arg(long, value_name = "N")]
max_nodes: Option,
/// Maximum entries in a ZIP container [default: 10000]
@@ -200,6 +223,13 @@ async fn main() -> Result<()> {
limits,
rules,
json: output_json,
+ layout,
+ ocr,
+ ocr_language,
+ ocr_backend,
+ ocr_detection_model,
+ ocr_recognition_model,
+ remove_pdf_headers,
base_url,
main_content,
} => {
@@ -211,8 +241,22 @@ async fn main() -> Result<()> {
let mut builder = Markkit::builder()
.limits(limits.apply(Limits::default()))
+ .pdf_layout(layout)
+ .pdf_ocr(ocr)
+ .pdf_ocr_language(ocr_language.unwrap_or_else(|| "eng".into()))
+ .pdf_remove_repeated_headers(remove_pdf_headers)
.main_content(main_content);
+ if let Some(backend) = ocr_backend {
+ builder = builder.pdf_ocr_backend(backend.parse().map_err(anyhow::Error::msg)?);
+ }
+ if let Some(path) = ocr_detection_model {
+ builder = builder.pdf_ocr_detection_model(path);
+ }
+ if let Some(path) = ocr_recognition_model {
+ builder = builder.pdf_ocr_recognition_model(path);
+ }
+
for sel in &include_selectors {
builder = builder.include_selector(sel);
}
@@ -251,13 +295,18 @@ async fn main() -> Result<()> {
};
if output_json {
- let output = serde_json::json!({
+ let mut output = serde_json::json!({
"source": source_label,
"markdown": extraction.markdown,
"format": extraction.format,
"title": extraction.title,
"metadata": extraction.metadata,
+ "stats": extraction.stats,
+ "warnings": extraction.warnings,
});
+ if let Some(layout) = extraction.layout {
+ output["layout"] = serde_json::to_value(layout)?;
+ }
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
print!("{}", extraction.markdown);
diff --git a/crates/markkit-cli/tests/cli_integration.rs b/crates/markkit-cli/tests/cli_integration.rs
index 3d3cdd9..dfab278 100644
--- a/crates/markkit-cli/tests/cli_integration.rs
+++ b/crates/markkit-cli/tests/cli_integration.rs
@@ -168,6 +168,44 @@ fn extract_json_output() {
assert!(parsed["markdown"].as_str().unwrap().contains("JSON Test"));
}
+#[test]
+fn pdf_layout_requires_json_and_is_returned_for_pdf() {
+ let invalid = markkit_bin()
+ .args(["extract", "--stdin", "--layout"])
+ .output()
+ .unwrap();
+ assert!(!invalid.status.success());
+ assert!(String::from_utf8_lossy(&invalid.stderr).contains("--json"));
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("layout.pdf");
+ // A repairable PDF with one page and no text, no external fixture needed.
+ std::fs::write(&path, b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF").unwrap();
+ let output = markkit_bin()
+ .args([
+ "extract",
+ "--file",
+ path.to_str().unwrap(),
+ "--json",
+ "--layout",
+ ])
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(value["layout"]["pages"][0]["width"], 300.0);
+ assert_eq!(value["layout"]["pages"][0]["height"], 400.0);
+ assert!(
+ value["layout"]["pages"][0]["spans"]
+ .as_array()
+ .unwrap()
+ .is_empty()
+ );
+}
+
#[test]
fn extract_with_include_selector() {
let mut child = markkit_bin()
@@ -302,3 +340,16 @@ fn extract_with_bad_rules_file_fails() {
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("invalid rules file"));
}
+
+#[test]
+fn ocr_backend_flags_validate_names_and_require_ocr() {
+ for args in [
+ vec!["extract", "--stdin", "--ocr-backend", "ocrs"],
+ vec!["extract", "--stdin", "--ocr", "--ocr-backend", "unknown"],
+ vec!["extract", "--stdin", "--ocr-detection-model", "model.onnx"],
+ ] {
+ let output = markkit_bin().args(args).output().unwrap();
+ assert!(!output.status.success());
+ assert_eq!(output.status.code(), Some(2));
+ }
+}
diff --git a/crates/markkit-eval/Cargo.toml b/crates/markkit-eval/Cargo.toml
index 8a8f84b..fc48b3e 100644
--- a/crates/markkit-eval/Cargo.toml
+++ b/crates/markkit-eval/Cargo.toml
@@ -23,8 +23,8 @@ path = "src/main.rs"
# Mira eval framework (crates.io), imported as `mira`. `macros` provides the
# #[eval] discovery attribute. In-process subject only: no mira-everruns.
mira-eval = { version = "0.5", features = ["macros"] }
-# HTML only, no HTTP client: pages are embedded, markkit never fetches.
-markkit = { path = "../markkit", default-features = false, features = ["html"] }
+# Embedded HTML and generated PDF scenes; markkit never fetches.
+markkit = { path = "../markkit", default-features = false, features = ["html", "pdf"] }
serde.workspace = true
serde_json.workspace = true
# LLM evals (markkit_qa) call Anthropic / OpenRouter. Eval crate only: the
diff --git a/crates/markkit-eval/data/pdf-layout.json b/crates/markkit-eval/data/pdf-layout.json
new file mode 100644
index 0000000..15b85af
--- /dev/null
+++ b/crates/markkit-eval/data/pdf-layout.json
@@ -0,0 +1,1720 @@
+[
+ {
+ "id": "simple",
+ "category": "text",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td (Hello) Tj ET"
+ }
+ ],
+ "markdown": "Hello\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Hello",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 97.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 97.0,
+ 94.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "cropped",
+ "category": "crop",
+ "pages": [
+ {
+ "attrs": "/CropBox [50 100 450 700] /UserUnit 2",
+ "content": "BT /F1 10 Tf 100 200 Td (Hello) Tj ET"
+ }
+ ],
+ "markdown": "Hello\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 800,
+ "height": 1200,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Hello",
+ "font": "Helvetica",
+ "size": 20,
+ "angle": 0,
+ "baseline": [
+ [
+ 100,
+ 1000
+ ],
+ [
+ 150.0,
+ 1000
+ ]
+ ],
+ "bbox": [
+ 100,
+ 984.0,
+ 150.0,
+ 1004.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "rotate-90",
+ "category": "rotation",
+ "pages": [
+ {
+ "attrs": "/Rotate 90",
+ "content": "BT /F1 10 Tf 72 700 Td (Hello) Tj ET"
+ }
+ ],
+ "markdown": "Hello\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 792,
+ "height": 612,
+ "rotation": 90,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Hello",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 90,
+ "baseline": [
+ [
+ 700,
+ 72
+ ],
+ [
+ 700,
+ 97
+ ]
+ ],
+ "bbox": [
+ 698,
+ 72,
+ 708,
+ 97
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "rotate-180",
+ "category": "rotation",
+ "pages": [
+ {
+ "attrs": "/Rotate 180",
+ "content": "BT /F1 10 Tf 72 700 Td (Hello) Tj ET"
+ }
+ ],
+ "markdown": "Hello\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 180,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Hello",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 180,
+ "baseline": [
+ [
+ 540,
+ 700
+ ],
+ [
+ 515,
+ 700
+ ]
+ ],
+ "bbox": [
+ 515,
+ 698,
+ 540,
+ 708
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "rotate-270",
+ "category": "rotation",
+ "pages": [
+ {
+ "attrs": "/Rotate 270",
+ "content": "BT /F1 10 Tf 72 700 Td (Hello) Tj ET"
+ }
+ ],
+ "markdown": "Hello\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 792,
+ "height": 612,
+ "rotation": 270,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Hello",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 270,
+ "baseline": [
+ [
+ 92,
+ 540
+ ],
+ [
+ 92,
+ 515
+ ]
+ ],
+ "bbox": [
+ 84,
+ 515,
+ 94,
+ 540
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "text-rotated",
+ "category": "rotation",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 0 1 -1 0 100 200 Tm (Hello) Tj ET"
+ }
+ ],
+ "markdown": "Hello\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Hello",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 270,
+ "baseline": [
+ [
+ 100,
+ 592
+ ],
+ [
+ 100,
+ 567
+ ]
+ ],
+ "bbox": [
+ 92,
+ 567,
+ 102,
+ 592
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "tj-spacing",
+ "category": "spacing",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td [(Hi) -500 (there)] TJ ET"
+ }
+ ],
+ "markdown": "Hi there\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Hi",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 82.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 82.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "there",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 87,
+ 92
+ ],
+ [
+ 112.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 87,
+ 84.0,
+ 112.0,
+ 94.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "scaled",
+ "category": "matrix",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "q 2 0 0 2 10 20 cm BT /F1 10 Tf 10 100 Td (Hi) Tj ET Q"
+ }
+ ],
+ "markdown": "Hi\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Hi",
+ "font": "Helvetica",
+ "size": 20,
+ "angle": 0,
+ "baseline": [
+ [
+ 30,
+ 572
+ ],
+ [
+ 50.0,
+ 572
+ ]
+ ],
+ "bbox": [
+ 30,
+ 556.0,
+ 50.0,
+ 576.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "empty",
+ "category": "empty",
+ "pages": [
+ {
+ "attrs": "",
+ "content": ""
+ }
+ ],
+ "markdown": "",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": []
+ }
+ ]
+ }
+ },
+ {
+ "id": "shuffled-columns",
+ "category": "columns",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td (Left first) Tj ET\nBT /F1 10 Tf 300 700 Td (Right first) Tj ET\nBT /F1 10 Tf 72 686 Td (Left second) Tj ET\nBT /F1 10 Tf 300 686 Td (Right second) Tj ET"
+ }
+ ],
+ "markdown": "Left first Left second\n\nRight first Right second\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Left first",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 122.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 122.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "Right first",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 92
+ ],
+ [
+ 355.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 300,
+ 84.0,
+ 355.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 2,
+ "text": "Left second",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 106
+ ],
+ [
+ 127.0,
+ 106
+ ]
+ ],
+ "bbox": [
+ 72,
+ 98.0,
+ 127.0,
+ 108.0
+ ]
+ },
+ {
+ "id": 3,
+ "text": "Right second",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 106
+ ],
+ [
+ 360.0,
+ 106
+ ]
+ ],
+ "bbox": [
+ 300,
+ 98.0,
+ 360.0,
+ 108.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "ruled-table",
+ "category": "table",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "72 660 200 60 re S\n72 680 m 272 680 l S\n72 700 m 272 700 l S\n172 660 m 172 720 l S\nBT /F1 10 Tf 80 705 Td (Item) Tj ET\nBT /F1 10 Tf 180 705 Td (Qty) Tj ET\nBT /F1 10 Tf 80 685 Td (Apples) Tj ET\nBT /F1 10 Tf 180 685 Td (3) Tj ET\nBT /F1 10 Tf 80 665 Td (Pears) Tj ET\nBT /F1 10 Tf 180 665 Td (2) Tj ET"
+ }
+ ],
+ "markdown": "| Item | Qty |\n| --- | --- |\n| Apples | 3 |\n| Pears | 2 |\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Item",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 80,
+ 87
+ ],
+ [
+ 100.0,
+ 87
+ ]
+ ],
+ "bbox": [
+ 80,
+ 79.0,
+ 100.0,
+ 89.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "Qty",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 180,
+ 87
+ ],
+ [
+ 195.0,
+ 87
+ ]
+ ],
+ "bbox": [
+ 180,
+ 79.0,
+ 195.0,
+ 89.0
+ ]
+ },
+ {
+ "id": 2,
+ "text": "Apples",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 80,
+ 107
+ ],
+ [
+ 110.0,
+ 107
+ ]
+ ],
+ "bbox": [
+ 80,
+ 99.0,
+ 110.0,
+ 109.0
+ ]
+ },
+ {
+ "id": 3,
+ "text": "3",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 180,
+ 107
+ ],
+ [
+ 185.0,
+ 107
+ ]
+ ],
+ "bbox": [
+ 180,
+ 99.0,
+ 185.0,
+ 109.0
+ ]
+ },
+ {
+ "id": 4,
+ "text": "Pears",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 80,
+ 127
+ ],
+ [
+ 105.0,
+ 127
+ ]
+ ],
+ "bbox": [
+ 80,
+ 119.0,
+ 105.0,
+ 129.0
+ ]
+ },
+ {
+ "id": 5,
+ "text": "2",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 180,
+ 127
+ ],
+ [
+ 185.0,
+ 127
+ ]
+ ],
+ "bbox": [
+ 180,
+ 119.0,
+ 185.0,
+ 129.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "multiple-pages",
+ "category": "pages",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td (One) Tj ET"
+ },
+ {
+ "attrs": "",
+ "content": ""
+ },
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td (Three) Tj ET"
+ }
+ ],
+ "markdown": "One\n\nThree\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "One",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 87.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 87.0,
+ 94.0
+ ]
+ }
+ ]
+ },
+ {
+ "page": 2,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": []
+ },
+ {
+ "page": 3,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Three",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 97.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 97.0,
+ 94.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "borderless-table",
+ "category": "table",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td (Item) Tj ET\nBT /F1 10 Tf 300 700 Td (Qty) Tj ET\nBT /F1 10 Tf 72 684 Td (Apples) Tj ET\nBT /F1 10 Tf 300 684 Td (3) Tj ET\nBT /F1 10 Tf 72 668 Td (Pears) Tj ET\nBT /F1 10 Tf 300 668 Td (2) Tj ET\n"
+ }
+ ],
+ "markdown": "| Item | Qty |\n| --- | --- |\n| Apples | 3 |\n| Pears | 2 |\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Item",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 92.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 92.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "Qty",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 92
+ ],
+ [
+ 315.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 300,
+ 84.0,
+ 315.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 2,
+ "text": "Apples",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 108
+ ],
+ [
+ 102.0,
+ 108
+ ]
+ ],
+ "bbox": [
+ 72,
+ 100.0,
+ 102.0,
+ 110.0
+ ]
+ },
+ {
+ "id": 3,
+ "text": "3",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 108
+ ],
+ [
+ 305.0,
+ 108
+ ]
+ ],
+ "bbox": [
+ 300,
+ 100.0,
+ 305.0,
+ 110.0
+ ]
+ },
+ {
+ "id": 4,
+ "text": "Pears",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 124
+ ],
+ [
+ 97.0,
+ 124
+ ]
+ ],
+ "bbox": [
+ 72,
+ 116.0,
+ 97.0,
+ 126.0
+ ]
+ },
+ {
+ "id": 5,
+ "text": "2",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 124
+ ],
+ [
+ 305.0,
+ 124
+ ]
+ ],
+ "bbox": [
+ 300,
+ 116.0,
+ 305.0,
+ 126.0
+ ]
+ }
+ ],
+ "blocks": [
+ {
+ "id": 0,
+ "kind": "table",
+ "cells": [
+ {
+ "row": 0,
+ "column": 0,
+ "rowspan": 1,
+ "colspan": 1
+ },
+ {
+ "row": 0,
+ "column": 1,
+ "rowspan": 1,
+ "colspan": 1
+ },
+ {
+ "row": 1,
+ "column": 0,
+ "rowspan": 1,
+ "colspan": 1
+ },
+ {
+ "row": 1,
+ "column": 1,
+ "rowspan": 1,
+ "colspan": 1
+ },
+ {
+ "row": 2,
+ "column": 0,
+ "rowspan": 1,
+ "colspan": 1
+ },
+ {
+ "row": 2,
+ "column": 1,
+ "rowspan": 1,
+ "colspan": 1
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "wrapped-cell",
+ "category": "table",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td (Item) Tj ET\nBT /F1 10 Tf 300 700 Td (Qty) Tj ET\nBT /F1 10 Tf 72 684 Td (Long description) Tj ET\nBT /F1 10 Tf 300 684 Td (3) Tj ET\nBT /F1 10 Tf 72 672 Td (continued) Tj ET\nBT /F1 10 Tf 72 652 Td (Pears) Tj ET\nBT /F1 10 Tf 300 652 Td (2) Tj ET\n"
+ }
+ ],
+ "markdown": "| Item | Qty |\n| --- | --- |\n| Long description continued | 3 |\n| Pears | 2 |\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Item",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 92.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 92.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "Qty",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 92
+ ],
+ [
+ 315.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 300,
+ 84.0,
+ 315.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 2,
+ "text": "Long description",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 108
+ ],
+ [
+ 152.0,
+ 108
+ ]
+ ],
+ "bbox": [
+ 72,
+ 100.0,
+ 152.0,
+ 110.0
+ ]
+ },
+ {
+ "id": 3,
+ "text": "3",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 108
+ ],
+ [
+ 305.0,
+ 108
+ ]
+ ],
+ "bbox": [
+ 300,
+ 100.0,
+ 305.0,
+ 110.0
+ ]
+ },
+ {
+ "id": 4,
+ "text": "continued",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 120
+ ],
+ [
+ 117.0,
+ 120
+ ]
+ ],
+ "bbox": [
+ 72,
+ 112.0,
+ 117.0,
+ 122.0
+ ]
+ },
+ {
+ "id": 5,
+ "text": "Pears",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 140
+ ],
+ [
+ 97.0,
+ 140
+ ]
+ ],
+ "bbox": [
+ 72,
+ 132.0,
+ 97.0,
+ 142.0
+ ]
+ },
+ {
+ "id": 6,
+ "text": "2",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 140
+ ],
+ [
+ 305.0,
+ 140
+ ]
+ ],
+ "bbox": [
+ 300,
+ 132.0,
+ 305.0,
+ 142.0
+ ]
+ }
+ ],
+ "blocks": [
+ {
+ "id": 0,
+ "kind": "table"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "tagged-heading",
+ "category": "tags",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "/H2 BMC BT /F1 10 Tf 72 700 Td (Section) Tj ET EMC\nBT /F1 10 Tf 72 670 Td (Body) Tj ET"
+ }
+ ],
+ "markdown": "## Section\n\nBody\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Section",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 107.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 107.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "Body",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 122
+ ],
+ [
+ 92.0,
+ 122
+ ]
+ ],
+ "bbox": [
+ 72,
+ 114.0,
+ 92.0,
+ 124.0
+ ]
+ }
+ ],
+ "blocks": [
+ {
+ "id": 0,
+ "kind": "heading"
+ },
+ {
+ "id": 1,
+ "kind": "paragraph"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "ordered-list",
+ "category": "lists",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 14 TL 72 700 Td (3. first) Tj T* (4. second) Tj ET"
+ }
+ ],
+ "markdown": "3. first\n4. second\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "3. first",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 112.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 112.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "4. second",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 106
+ ],
+ [
+ 117.0,
+ 106
+ ]
+ ],
+ "bbox": [
+ 72,
+ 98.0,
+ 117.0,
+ 108.0
+ ]
+ }
+ ],
+ "blocks": [
+ {
+ "id": 0,
+ "kind": "list"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "actual-text",
+ "category": "tags",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td /Span << /ActualText (Accessible text) >> BDC (junk) Tj (junk2) Tj EMC ET"
+ }
+ ],
+ "markdown": "Accessible text\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Accessible text",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 117,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84,
+ 117,
+ 94
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "spanning-heading",
+ "category": "columns",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 20 Tf 72 740 Td (Title across columns) Tj ET\nBT /F1 10 Tf 72 700 Td (Left first) Tj ET\nBT /F1 10 Tf 300 700 Td (Right first) Tj ET\nBT /F1 10 Tf 72 686 Td (Left second) Tj ET\nBT /F1 10 Tf 300 686 Td (Right second) Tj ET\n"
+ }
+ ],
+ "markdown": "# Title across columns\n\nLeft first Left second\n\nRight first Right second\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Title across columns",
+ "font": "Helvetica",
+ "size": 20,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 52
+ ],
+ [
+ 272.0,
+ 52
+ ]
+ ],
+ "bbox": [
+ 72,
+ 36.0,
+ 272.0,
+ 56.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "Left first",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 122.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 122.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 2,
+ "text": "Right first",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 92
+ ],
+ [
+ 355.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 300,
+ 84.0,
+ 355.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 3,
+ "text": "Left second",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 106
+ ],
+ [
+ 127.0,
+ 106
+ ]
+ ],
+ "bbox": [
+ 72,
+ 98.0,
+ 127.0,
+ 108.0
+ ]
+ },
+ {
+ "id": 4,
+ "text": "Right second",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 300,
+ 106
+ ],
+ [
+ 360.0,
+ 106
+ ]
+ ],
+ "bbox": [
+ 300,
+ 98.0,
+ 360.0,
+ 108.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "narrow-gutter",
+ "category": "columns",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 700 Td (Long left line filling the space completely.) Tj ET\nBT /F1 10 Tf 302 703 Td (Right first) Tj ET\nBT /F1 10 Tf 72 686 Td (Another left line filling the space fully.) Tj ET\nBT /F1 10 Tf 302 689 Td (Right second) Tj ET"
+ }
+ ],
+ "markdown": "Long left line filling the space completely. Another left line filling the space fully.\n\nRight first Right second\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Long left line filling the space completely.",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 292.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 292.0,
+ 94.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "Right first",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 302,
+ 89
+ ],
+ [
+ 357.0,
+ 89
+ ]
+ ],
+ "bbox": [
+ 302,
+ 81.0,
+ 357.0,
+ 91.0
+ ]
+ },
+ {
+ "id": 2,
+ "text": "Another left line filling the space fully.",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 106
+ ],
+ [
+ 282.0,
+ 106
+ ]
+ ],
+ "bbox": [
+ 72,
+ 98.0,
+ 282.0,
+ 108.0
+ ]
+ },
+ {
+ "id": 3,
+ "text": "Right second",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 302,
+ 103
+ ],
+ [
+ 362.0,
+ 103
+ ]
+ ],
+ "bbox": [
+ 302,
+ 95.0,
+ 362.0,
+ 105.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "shuffled-lines",
+ "category": "order",
+ "pages": [
+ {
+ "attrs": "",
+ "content": "BT /F1 10 Tf 72 686 Td (Second line) Tj ET\nBT /F1 10 Tf 72 700 Td (First line) Tj ET"
+ }
+ ],
+ "markdown": "First line Second line\n",
+ "layout": {
+ "version": 1,
+ "pages": [
+ {
+ "page": 1,
+ "width": 612,
+ "height": 792,
+ "rotation": 0,
+ "spans": [
+ {
+ "id": 0,
+ "text": "Second line",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 106
+ ],
+ [
+ 127.0,
+ 106
+ ]
+ ],
+ "bbox": [
+ 72,
+ 98.0,
+ 127.0,
+ 108.0
+ ]
+ },
+ {
+ "id": 1,
+ "text": "First line",
+ "font": "Helvetica",
+ "size": 10,
+ "angle": 0,
+ "baseline": [
+ [
+ 72,
+ 92
+ ],
+ [
+ 122.0,
+ 92
+ ]
+ ],
+ "bbox": [
+ 72,
+ 84.0,
+ 122.0,
+ 94.0
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ }
+]
diff --git a/crates/markkit-eval/results/hayro-2026-10-29.json b/crates/markkit-eval/results/hayro-2026-10-29.json
new file mode 100644
index 0000000..7005a64
--- /dev/null
+++ b/crates/markkit-eval/results/hayro-2026-10-29.json
@@ -0,0 +1,431 @@
+{
+ "date": "2026-10-29",
+ "scope": "Replace optional PDF OCR Poppler rasterization with Rust Hayro; retain layout/tables and Tesseract recognition; audit remaining native dependencies.",
+ "source_revision": "7a4e62f0497deee864f39600e5a36a3d348260bf",
+ "source_dirty": true,
+ "candidate_binary_sha256": "54ac90378f5c89ecad827836306e363641c3bfe79585365c00d2de6b34d8db47",
+ "provenance_review": {
+ "project": "https://github.com/LaurenzV/hayro",
+ "registry_release": "0.8.0",
+ "registry_checksum": "4675ff5acd7ca41c0c34ccd2a3a154f5c86d8b5ec92bef0a114bfb194969e2af",
+ "registry_owners": [
+ "LaurenzV"
+ ],
+ "maintainer_public_name": "Laurenz Stampfl",
+ "maintainer_public_location": "Vienna, Austria",
+ "earliest_surviving_default_branch_commit": "38efa5f7d08e68ebca2d1a209d4921c1deffeaf3",
+ "earliest_commit_date": "2025-04-22",
+ "finding": "No Russian organizational ownership found in public repository/registry records. Does not certify every contributor, historical affiliation or transitive dependency author."
+ },
+ "public_default_regression": {
+ "cases": 178,
+ "regressions": [],
+ "quality": {
+ "agentdocbench/category/cjk": 0.9335,
+ "agentdocbench/category/docx": 0.9988666666666667,
+ "agentdocbench/category/epub": 0.9891333333333333,
+ "agentdocbench/category/financial": 0.49095,
+ "agentdocbench/category/form": 0.8377666666666667,
+ "agentdocbench/category/html": 1.0,
+ "agentdocbench/category/paper": 0.8956333333333333,
+ "agentdocbench/category/report": 0.8705,
+ "agentdocbench/category/scanned": 0.0,
+ "agentdocbench/category/slides": 0.75135,
+ "agentdocbench/category/spreadsheet": 1.0,
+ "agentdocbench/category/two-column": 0.7586666666666667,
+ "agentdocbench/format/csv": 1.0,
+ "agentdocbench/format/docx": 0.9988666666666667,
+ "agentdocbench/format/epub": 0.9891333333333333,
+ "agentdocbench/format/html": 1.0,
+ "agentdocbench/format/pdf": 0.6553260869565217,
+ "agentdocbench/format/pptx": 0.9572,
+ "agentdocbench/format/xlsx": 1.0,
+ "agentdocbench/no_ocr": 0.8660333333333333,
+ "agentdocbench/overall": 0.7938638888888889,
+ "wcxb/default/overall": 0.6770940834789322,
+ "wcxb/default/type/article": 0.8593022688301003,
+ "wcxb/default/type/collection": 0.5618264586879532,
+ "wcxb/default/type/documentation": 0.849698642479727,
+ "wcxb/default/type/forum": 0.4559187546618804,
+ "wcxb/default/type/listing": 0.6660601098285603,
+ "wcxb/default/type/product": 0.5220558838570278,
+ "wcxb/default/type/service": 0.8247964660072756,
+ "wcxb/main_content/overall": 0.4999264653091694,
+ "wcxb/main_content/type/article": 0.9687139672138977,
+ "wcxb/main_content/type/collection": 0.484765233729096,
+ "wcxb/main_content/type/documentation": 0.7157843169028515,
+ "wcxb/main_content/type/forum": 0.10889386123735083,
+ "wcxb/main_content/type/listing": 0.33261257051933946,
+ "wcxb/main_content/type/product": 0.4767832465487646,
+ "wcxb/main_content/type/service": 0.41193206101288565
+ },
+ "provenance": {
+ "binary_sha256": "54ac90378f5c89ecad827836306e363641c3bfe79585365c00d2de6b34d8db47",
+ "build_profile": "release",
+ "command": [
+ "/workspace/markkit/target/public-benchmarks/bin/mira",
+ "run",
+ "--study-python",
+ "scripts/public_benchmarks/study.py",
+ "--max-concurrent",
+ "1",
+ "--max-retries",
+ "0",
+ "--timeout",
+ "300",
+ "--group-by",
+ "category",
+ "--format",
+ "json",
+ "--out",
+ "/workspace/markkit/work/hayro-review/public/mira.json"
+ ],
+ "core_diff_sha256": "d558790fae49d8a697de670706a70621ac0b787bfe2a903cfa51e2d1b46dfd50",
+ "cpu_count": 5,
+ "machine": "x86_64",
+ "mira_version": "mira 0.5.0",
+ "pins": {
+ "agentdocbench": "e97ccc4a2be9c9a71289c4e000e60e953daccaa3",
+ "mira": "v0.5.0",
+ "mira_linux_archive_sha256": "b69d1a9af34d83aae87aad2453cf642739de9e0859e043b2b6571839ef7462fc",
+ "mira_sdk": "334506ef19a1d975dace0f2c51092548a10c7b3e",
+ "samples_per_type": 10,
+ "seed": "markkit-public-pilot-v1:",
+ "wcxb": "c039d5ee9f5a3a984a0e167e63aacd04e76e78a9"
+ },
+ "platform": "Linux-6.18.55-x86_64-with-glibc2.41",
+ "python": "3.12.14",
+ "rustc_version": "rustc 1.99.1 (58cf8f9c3 2026-10-13)",
+ "source_dirty": true,
+ "source_revision": "7a4e62f0497deee864f39600e5a36a3d348260bf",
+ "timing_boundary": "median of 3 fresh CLI processes after 1 warm-up; sequential; includes startup"
+ }
+ },
+ "pdf_scenes": {
+ "cases": 40,
+ "passed": 40,
+ "samples": 20,
+ "modes": [
+ "default",
+ "layout"
+ ]
+ },
+ "public_pdf_geometry": {
+ "documents": 23,
+ "pages": 243,
+ "cases": [
+ {
+ "id": "paper-evsched",
+ "pages": 23,
+ "spans": 12924,
+ "blocks": 310,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "paper-rankone",
+ "pages": 27,
+ "spans": 35955,
+ "blocks": 2302,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "paper-ulam",
+ "pages": 11,
+ "spans": 21304,
+ "blocks": 287,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "twocol-bygpt5",
+ "pages": 16,
+ "spans": 16572,
+ "blocks": 542,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "twocol-smile",
+ "pages": 13,
+ "spans": 11879,
+ "blocks": 733,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "twocol-uscore",
+ "pages": 17,
+ "spans": 16510,
+ "blocks": 502,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "report-crs-dod-approps",
+ "pages": 12,
+ "spans": 10842,
+ "blocks": 397,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "report-plz",
+ "pages": 5,
+ "spans": 5502,
+ "blocks": 52,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "fed-h8",
+ "pages": 22,
+ "spans": 10785,
+ "blocks": 2841,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "fin-bls-realer",
+ "pages": 5,
+ "spans": 6722,
+ "blocks": 571,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "fin-census-income",
+ "pages": 10,
+ "spans": 7424,
+ "blocks": 743,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "invoice-borderless",
+ "pages": 2,
+ "spans": 113,
+ "blocks": 42,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "form-1040es",
+ "pages": 12,
+ "spans": 5334,
+ "blocks": 631,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "form-w4",
+ "pages": 4,
+ "spans": 1739,
+ "blocks": 189,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "irs-w9",
+ "pages": 6,
+ "spans": 1074,
+ "blocks": 199,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "scan-nasa-budget",
+ "pages": 3,
+ "spans": 0,
+ "blocks": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "scan-nasa-notes",
+ "pages": 3,
+ "spans": 0,
+ "blocks": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "scan-oklahoma-address",
+ "pages": 7,
+ "spans": 0,
+ "blocks": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "cjk-jp-stat-elderly",
+ "pages": 4,
+ "spans": 2797,
+ "blocks": 190,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "cjk-tw-archives",
+ "pages": 4,
+ "spans": 2385,
+ "blocks": 15,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "cjk-tw-dgbas-cpi",
+ "pages": 6,
+ "spans": 3614,
+ "blocks": 368,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "slides-nasa-clarreo",
+ "pages": 15,
+ "spans": 5715,
+ "blocks": 160,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "slides-nist-pqc-update",
+ "pages": 16,
+ "spans": 4054,
+ "blocks": 190,
+ "markdown_unchanged": true
+ }
+ ]
+ },
+ "ocr": {
+ "documents": 3,
+ "poppler_mean_document_score": 0.5233666666666666,
+ "hayro_mean_document_score": 0.5604666666666667,
+ "cases": [
+ {
+ "id": "scan-nasa-budget",
+ "scores": {
+ "document_score": 0.3253,
+ "sentences": 0.2,
+ "order": 0.6667,
+ "table_cells_f1": 0.1091
+ },
+ "seconds": 5.4309253960000206,
+ "spans": 839,
+ "binary_sha256": "54ac90378f5c89ecad827836306e363641c3bfe79585365c00d2de6b34d8db47",
+ "baseline_score": 0.353,
+ "score_delta": -0.0277
+ },
+ {
+ "id": "scan-nasa-notes",
+ "scores": {
+ "document_score": 0.4394,
+ "sentences": 0.5,
+ "order": 0.8182,
+ "table_cells_f1": 0.0
+ },
+ "seconds": 5.464721544998611,
+ "spans": 785,
+ "binary_sha256": "54ac90378f5c89ecad827836306e363641c3bfe79585365c00d2de6b34d8db47",
+ "baseline_score": 0.3838,
+ "score_delta": 0.0556
+ },
+ {
+ "id": "scan-oklahoma-address",
+ "scores": {
+ "document_score": 0.9167,
+ "sentences": 0.8333333333333334,
+ "order": 1.0
+ },
+ "seconds": 14.05723030000081,
+ "spans": 3174,
+ "binary_sha256": "54ac90378f5c89ecad827836306e363641c3bfe79585365c00d2de6b34d8db47",
+ "baseline_score": 0.8333,
+ "score_delta": 0.0834
+ }
+ ],
+ "regressed_cases": [
+ "scan-nasa-budget"
+ ],
+ "configuration": "--ocr --timeout 300 --json --layout; 1800px white-backed grayscale PGM; interpolation forced; explicit page DPI; Tesseract eng with one OpenMP thread; PATH contains only Tesseract (no Poppler)",
+ "timing_note": "Observed end-to-end wall time only; concurrent build activity and cache differences prevent a controlled performance comparison."
+ },
+ "validation": {
+ "workspace_all_features": {
+ "passed": 820,
+ "failed": 0,
+ "ignored": 1
+ },
+ "ocr_integration_explicit": {
+ "passed": 4,
+ "failed": 0,
+ "ignored": 0
+ },
+ "pdf_only_tests": {
+ "passed": 134,
+ "failed": 0,
+ "ignored": 0
+ },
+ "format": "passed",
+ "knowledge": "OKF v0.2 conformant",
+ "clippy": "passed",
+ "clippy-pdf-only": "passed",
+ "workspace-build": "passed",
+ "wasm-build": "passed",
+ "rustdoc": "passed",
+ "node": {
+ "passed": 10,
+ "failed": 0
+ },
+ "wasm": {
+ "passed": 23,
+ "failed": 0
+ },
+ "python": {
+ "passed": 2,
+ "failed": 0
+ }
+ },
+ "native_dependencies": {
+ "production": [
+ "Tesseract C++ and Leptonica C, host-installed for optional OCR; distribution-specific codec libraries",
+ "AWS-LC C/assembly for optional http_client TLS",
+ "CPython C / Node V8 C++ host runtimes for bindings",
+ "OS APIs and platform certificate stores"
+ ],
+ "development_only": [
+ "alloca C shim via Criterion"
+ ],
+ "not_enabled": [
+ "ring is in lockfile but not the active reviewed all-features graph"
+ ],
+ "renderer": "Hayro and its rendering/codec/font/color libraries are Rust; no Poppler executable required."
+ },
+ "limits_and_tradeoffs": [
+ "OCR mean improves, but NASA Budget falls from 0.3530 to 0.3253; per-case regression remains visible.",
+ "Hayro rendering is non-interruptible with cooperative deadline checks before/after; host isolation provides hard CPU/memory quotas.",
+ "JPEG, header-bounded JPEG2000 and generic-filter image OCR are supported. Inline images, DecodeParms, JBIG2 and CCITT OCR fail explicitly until bounded decoder support exists.",
+ "Per-image working estimate is 16 bytes per pixel; cumulative RGBA estimate is four bytes per pixel. These estimates are not a hard heap quota.",
+ "All existing layout/table inference limitations remain; no corpus/baseline/scorer changes."
+ ],
+ "changed_source_sha256": {
+ "Cargo.lock": "5f00819f5dbc9ed5dbb2ca5fe1152b592af34ef6d6f125aa8a40861c0b780b1b",
+ "Cargo.toml": "16622c0ddc29a8556ec93743b0c05cfa73974679590f70029dc615e364e4ffa6",
+ "crates/markkit-cli/Cargo.toml": "00ca964fa09dbb701e54dd43895927bd193877dccb85664352e6f15f68c61de0",
+ "crates/markkit-cli/src/main.rs": "13a93ed3ad55e1244eb0e285aa343e56e2e9720383cd1fcdb06795c825f2b696",
+ "crates/markkit-cli/tests/cli_integration.rs": "7cecfb9131c1cc446bdc3438a448cd630b1b0d440e7fa26641dcb47177ce5aad",
+ "crates/markkit-eval/Cargo.toml": "903b131f5716708491924ea56af529e681a980c499223d909cab1b93e43fee7d",
+ "crates/markkit-eval/data/pdf-layout.json": "38f5ac20041441fad3f6efcbe5a2f8ca82d408339c953971ccf036ebf856d42b",
+ "crates/markkit-eval/src/lib.rs": "ee1e9ac8ec5113fbb2b0a2cea6948e07905d9cc0d021c49ee212fc3f51ddf843",
+ "crates/markkit-eval/src/main.rs": "feb406d2f02b3fc35efd7a0647780dc7c66656852da1219e24b17f848f70720e",
+ "crates/markkit-eval/src/pdf.rs": "43bc3ee3b2fbd358068361bdce611710859ada341a1c631d566f367ebe6d6319",
+ "crates/markkit-js/Cargo.toml": "8c1465370c370169f46de4d689ee7a65d49cc2a6d021ae9f24df4b75aaa773e6",
+ "crates/markkit-js/__test__/options.test.cjs": "551806085f99d27176e798eeb224c6d99828fd446573ca2641b2896b7df5851f",
+ "crates/markkit-js/src/lib.rs": "d95f076585da9f14ef29d8e622ddb3566ce6db46655c7570208807bd6275b316",
+ "crates/markkit-python/Cargo.toml": "60a08f0895531f9f4e47b9df5058c1f48b202449256f587c6f2b802d3295b86f",
+ "crates/markkit-python/src/lib.rs": "bed7b5ba5ec910e4561117599f88e83a9ff054d6555cc15012e28d0c3548326c",
+ "crates/markkit-python/tests/test_documents.py": "8bad0e0698412f3f26c0af583b16e2dbfb23f04aeaf5c2892875f21183b1b243",
+ "crates/markkit-wasm/Cargo.toml": "6b59cdb571c528ce4f362e5d0713e2ef0aab29eb928cd44579855cef2c9095cc",
+ "crates/markkit-wasm/__test__/markkit-wasm.test.mjs": "6edd60dae6a6bc165b6c78e9ef1d5134b21a5ab10f4509fe5e0953468612209b",
+ "crates/markkit-wasm/js/index.d.ts": "3f6c9853e1dd753ef940fa667827bff52d34a2707526ac243e6360082528c404",
+ "crates/markkit-wasm/src/lib.rs": "85791162e8d7862019c1b7da621a28ee4b2bb7789fafa04ccac1004eb72ad1d3",
+ "crates/markkit/Cargo.toml": "ac7cf6249887b6fc346206506097acefae230fd605224380ec0ab5eae48f5436",
+ "crates/markkit/src/config.rs": "829c85678fc0eee0b8912af173a8d175f125763af4a8ad90eca1f13ed97e1a69",
+ "crates/markkit/src/extract.rs": "87b796ff60a4f995e6c6c308e480d132281f53983b9a76a66da021b935e2ed5e",
+ "crates/markkit/src/ir.rs": "b7a121efe1e498697698176bf2b779631f76a5daf511aebd74b6fa97c0b368b2",
+ "crates/markkit/src/layout.rs": "1979d867083314b2d5550bfe80b2c1a85f7da3a3bc5056af13379080b40bb623",
+ "crates/markkit/src/lib.rs": "78db7fa50930b8d60e123ac0c199b27376667b360b93da34dc7a19cc3b1f3f63",
+ "crates/markkit/src/limits.rs": "a197331637943ef907791697f1cb1c3270e35a6b5f7aed8480464bd01b00e0cc",
+ "crates/markkit/src/pdf/analyze.rs": "f27c781852039c574b72a6cc71594ac04562d39134794311b97e8dfb6252aa4d",
+ "crates/markkit/src/pdf/font.rs": "32c2569858d14ffb1cc6caf6419d79c25dce9c57bb33e1741a8714a728fdf02e",
+ "crates/markkit/src/pdf/guard.rs": "489d3bf291c63656ecd0181318dd4fffd7ac300e89ad029428814f3f2660d07e",
+ "crates/markkit/src/pdf/layout.rs": "8c9be884482158a6832989dab75936381cea955419d122e710e5b195478e88c6",
+ "crates/markkit/src/pdf/mod.rs": "95c0f19f28f49569c7f554166b67b99aa335eadc88c129b140cc7a1a75a3ada2",
+ "crates/markkit/src/pdf/ocr.rs": "40fb7b6774646653d1a67a5a2a5dfe56ec6f15350bfec6d24128a4afa830a70f",
+ "crates/markkit/src/pdf/tags.rs": "9e925845654113cdc2fecdf5a96f55377c8a23bd8443f9b70494a7dbde71cced",
+ "crates/markkit/tests/pdf_layout.rs": "9db16686713b59ae187bd315c8c47416bd7f67925750f80011c88f6f6cd2964a",
+ "crates/markkit/tests/pdf_ocr.rs": "824fe669525deee5043420cfb13c0d91923b5bf1fa373d6cc000de021d040304",
+ "crates/markkit/tests/pdf_structure.rs": "fd941762bbee191421f3eb790906e25a9b7d6ce49e5f55f8641595a0a5c54ca8",
+ "schema/ir.schema.json": "3763054cb70c7ce75c86b0ec2a70948cbee17d2e60613256ec63d697e8fe1860"
+ }
+}
diff --git a/crates/markkit-eval/results/ocrs-2026-10-28.json b/crates/markkit-eval/results/ocrs-2026-10-28.json
new file mode 100644
index 0000000..3ae763e
--- /dev/null
+++ b/crates/markkit-eval/results/ocrs-2026-10-28.json
@@ -0,0 +1,488 @@
+{
+ "scope": "Same three pinned public scans, shared Hayro rendering and Markkit layout/Markdown; OCR backend is the variable.",
+ "source_revision": "7a4e62f0497deee864f39600e5a36a3d348260bf",
+ "source_dirty": true,
+ "binary_sha256": "d77b9d69bc0de0964607f02ef4cdd126ab686d4af8442e7ad647b0806fa3961d",
+ "model_pins": {
+ "source": "https://github.com/robertknight/ocrs/blob/main/ocrs/examples/download-models.sh",
+ "revision": "Official example model pair, content pinned by SHA256",
+ "license": "CC-BY-SA-4.0",
+ "license_url": "https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md",
+ "attribution": "Ocrs models by Robert Knight; trained on HierText (CC-BY-SA 4.0).",
+ "models": [
+ {
+ "name": "text-detection.onnx",
+ "url": "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx",
+ "sha256": "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe",
+ "size": 2499479,
+ "last_modified": "Sun, 13 Sep 2026 08:22:54 GMT"
+ },
+ {
+ "name": "text-recognition.onnx",
+ "url": "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx",
+ "sha256": "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5",
+ "size": 9713177,
+ "last_modified": "Sun, 13 Sep 2026 08:22:52 GMT"
+ }
+ ]
+ },
+ "platform": "Linux-6.18.55-x86_64-with-glibc2.41",
+ "versions": {
+ "ocrs": "0.13.1",
+ "rten": "0.26.0",
+ "tesseract": [
+ "tesseract 5.5.0",
+ " leptonica-1.84.1"
+ ]
+ },
+ "method": {
+ "profile": "release",
+ "warmups": 1,
+ "runs": 3,
+ "thread_environment": {
+ "OMP_THREAD_LIMIT": "1",
+ "RTEN_NUM_THREADS": "1",
+ "RAYON_NUM_THREADS": "1"
+ },
+ "timing": "Fresh process end-to-end, including model loading and page rendering; scoring outside boundary.",
+ "memory": "Peak sum of process-tree resident bytes, sampled every 10ms on Linux. Shared pages may be counted multiple times; inference buffers are included.",
+ "timeout_seconds": 300,
+ "ocr_language": "eng",
+ "raster_max_dimension": 1800,
+ "ocrs_max_lines_per_batch": 20
+ },
+ "pure_rust_ocr_helper_check": "All ocrs executions used an empty PATH.",
+ "cases": [
+ {
+ "id": "scan-nasa-budget",
+ "input_sha256": "f0637cdd4026471afa0cf1ab199a4030844ee619f0a6dd727ceab5f29717e847",
+ "engines": {
+ "tesseract": {
+ "scores": {
+ "document_score": 0.3253,
+ "sentences": 0.2,
+ "order": 0.6667,
+ "table_cells_f1": 0.1091
+ },
+ "geometry": {
+ "pages": 3,
+ "words": 839,
+ "valid": true,
+ "tables": 1
+ },
+ "median_seconds": 4.277916884999286,
+ "median_peak_process_tree_rss_bytes": 37949440,
+ "measurements": [
+ {
+ "seconds": 3.9396111330006534,
+ "peak_process_tree_rss_bytes": 37814272
+ },
+ {
+ "seconds": 4.277916884999286,
+ "peak_process_tree_rss_bytes": 37949440
+ },
+ {
+ "seconds": 4.448720090998904,
+ "peak_process_tree_rss_bytes": 37949440
+ }
+ ]
+ },
+ "ocrs": {
+ "scores": {
+ "document_score": 0.3167,
+ "sentences": 0.2,
+ "order": 0.75,
+ "table_cells_f1": 0.0
+ },
+ "geometry": {
+ "pages": 3,
+ "words": 840,
+ "valid": true,
+ "tables": 0
+ },
+ "median_seconds": 12.777047276998928,
+ "median_peak_process_tree_rss_bytes": 238678016,
+ "measurements": [
+ {
+ "seconds": 12.566175017000205,
+ "peak_process_tree_rss_bytes": 229179392
+ },
+ {
+ "seconds": 12.777047276998928,
+ "peak_process_tree_rss_bytes": 238678016
+ },
+ {
+ "seconds": 13.25562496999919,
+ "peak_process_tree_rss_bytes": 238919680
+ }
+ ]
+ }
+ },
+ "geometry_agreement": {
+ "exact_text_matches": 712,
+ "mean_matched_word_bbox_iou": 0.7946531947851007,
+ "interpretation": "Cross-engine agreement only; these scans have no ground-truth word boxes."
+ }
+ },
+ {
+ "id": "scan-nasa-notes",
+ "input_sha256": "0d0ae5bc4a51949159beea4455d198852087e7fd6b95e9d6730a6248700975bf",
+ "engines": {
+ "tesseract": {
+ "scores": {
+ "document_score": 0.4394,
+ "sentences": 0.5,
+ "order": 0.8182,
+ "table_cells_f1": 0.0
+ },
+ "geometry": {
+ "pages": 3,
+ "words": 785,
+ "valid": true,
+ "tables": 0
+ },
+ "median_seconds": 3.745943695999813,
+ "median_peak_process_tree_rss_bytes": 36679680,
+ "measurements": [
+ {
+ "seconds": 4.393917142000646,
+ "peak_process_tree_rss_bytes": 36679680
+ },
+ {
+ "seconds": 3.701504170001499,
+ "peak_process_tree_rss_bytes": 36880384
+ },
+ {
+ "seconds": 3.745943695999813,
+ "peak_process_tree_rss_bytes": 36614144
+ }
+ ]
+ },
+ "ocrs": {
+ "scores": {
+ "document_score": 0.4141,
+ "sentences": 0.3333333333333333,
+ "order": 0.9091,
+ "table_cells_f1": 0.0
+ },
+ "geometry": {
+ "pages": 3,
+ "words": 787,
+ "valid": true,
+ "tables": 0
+ },
+ "median_seconds": 12.50714061600047,
+ "median_peak_process_tree_rss_bytes": 228642816,
+ "measurements": [
+ {
+ "seconds": 13.82876325000143,
+ "peak_process_tree_rss_bytes": 228769792
+ },
+ {
+ "seconds": 12.50714061600047,
+ "peak_process_tree_rss_bytes": 228642816
+ },
+ {
+ "seconds": 12.194090405999304,
+ "peak_process_tree_rss_bytes": 228585472
+ }
+ ]
+ }
+ },
+ "geometry_agreement": {
+ "exact_text_matches": 589,
+ "mean_matched_word_bbox_iou": 0.812589517515257,
+ "interpretation": "Cross-engine agreement only; these scans have no ground-truth word boxes."
+ }
+ },
+ {
+ "id": "scan-oklahoma-address",
+ "input_sha256": "f9da22937bda1617e919bb4a3b902551187bb484a07af254ccff22647ae81848",
+ "engines": {
+ "tesseract": {
+ "scores": {
+ "document_score": 0.9167,
+ "sentences": 0.8333333333333334,
+ "order": 1.0
+ },
+ "geometry": {
+ "pages": 7,
+ "words": 3174,
+ "valid": true,
+ "tables": 0
+ },
+ "median_seconds": 13.509221518999766,
+ "median_peak_process_tree_rss_bytes": 58048512,
+ "measurements": [
+ {
+ "seconds": 13.509221518999766,
+ "peak_process_tree_rss_bytes": 58048512
+ },
+ {
+ "seconds": 14.530162234999807,
+ "peak_process_tree_rss_bytes": 58155008
+ },
+ {
+ "seconds": 13.428984150999895,
+ "peak_process_tree_rss_bytes": 57786368
+ }
+ ]
+ },
+ "ocrs": {
+ "scores": {
+ "document_score": 0.9167,
+ "sentences": 0.8333333333333334,
+ "order": 1.0
+ },
+ "geometry": {
+ "pages": 7,
+ "words": 3165,
+ "valid": true,
+ "tables": 0
+ },
+ "median_seconds": 54.33059063300061,
+ "median_peak_process_tree_rss_bytes": 255877120,
+ "measurements": [
+ {
+ "seconds": 55.482910818000164,
+ "peak_process_tree_rss_bytes": 255877120
+ },
+ {
+ "seconds": 54.33059063300061,
+ "peak_process_tree_rss_bytes": 256438272
+ },
+ {
+ "seconds": 52.57790281199959,
+ "peak_process_tree_rss_bytes": 255811584
+ }
+ ]
+ }
+ },
+ "geometry_agreement": {
+ "exact_text_matches": 2600,
+ "mean_matched_word_bbox_iou": 0.7583267686781607,
+ "interpretation": "Cross-engine agreement only; these scans have no ground-truth word boxes."
+ }
+ }
+ ],
+ "engines": {
+ "tesseract": {
+ "mean_document_score": 0.5604666666666667,
+ "median_document_seconds": 4.277916884999286,
+ "median_peak_process_tree_rss_bytes": 37949440
+ },
+ "ocrs": {
+ "mean_document_score": 0.5491666666666667,
+ "median_document_seconds": 12.777047276998928,
+ "median_peak_process_tree_rss_bytes": 238678016
+ }
+ },
+ "date": "2026-10-28",
+ "timezone": "America/Chicago",
+ "validation": {
+ "workspace_all_features": {
+ "passed": 824,
+ "failed": 0,
+ "ignored": 2
+ },
+ "ocr_models_and_tesseract_explicit": {
+ "passed": 8,
+ "failed": 0,
+ "ignored": 0
+ },
+ "tesseract_feature_without_ocrs": {
+ "passed": 5,
+ "failed": 0,
+ "ignored": 1
+ },
+ "node": {
+ "passed": 11,
+ "failed": 0
+ },
+ "wasm": {
+ "passed": 23,
+ "failed": 0
+ },
+ "python": {
+ "passed": 3,
+ "failed": 0
+ },
+ "clippy": "passed",
+ "ocrs-only-clippy": "passed",
+ "rustdoc": "passed",
+ "repo_scripts": {
+ "run": 38,
+ "skipped": 2,
+ "failed": 0
+ },
+ "pdf_authored_scenes": {
+ "passed": 40,
+ "failed": 0
+ },
+ "public_default": {
+ "cases": 178,
+ "regressions": []
+ },
+ "format": "passed",
+ "knowledge": "OKF v0.2 conformant"
+ },
+ "public_default_regression": {
+ "cases": 178,
+ "regressions": [],
+ "quality": {
+ "agentdocbench/category/cjk": 0.9335,
+ "agentdocbench/category/docx": 0.9988666666666667,
+ "agentdocbench/category/epub": 0.9891333333333333,
+ "agentdocbench/category/financial": 0.49095,
+ "agentdocbench/category/form": 0.8377666666666667,
+ "agentdocbench/category/html": 1.0,
+ "agentdocbench/category/paper": 0.8956333333333333,
+ "agentdocbench/category/report": 0.8705,
+ "agentdocbench/category/scanned": 0.0,
+ "agentdocbench/category/slides": 0.75135,
+ "agentdocbench/category/spreadsheet": 1.0,
+ "agentdocbench/category/two-column": 0.7586666666666667,
+ "agentdocbench/format/csv": 1.0,
+ "agentdocbench/format/docx": 0.9988666666666667,
+ "agentdocbench/format/epub": 0.9891333333333333,
+ "agentdocbench/format/html": 1.0,
+ "agentdocbench/format/pdf": 0.6553260869565217,
+ "agentdocbench/format/pptx": 0.9572,
+ "agentdocbench/format/xlsx": 1.0,
+ "agentdocbench/no_ocr": 0.8660333333333333,
+ "agentdocbench/overall": 0.7938638888888889,
+ "wcxb/default/overall": 0.6770940834789322,
+ "wcxb/default/type/article": 0.8593022688301003,
+ "wcxb/default/type/collection": 0.5618264586879532,
+ "wcxb/default/type/documentation": 0.849698642479727,
+ "wcxb/default/type/forum": 0.4559187546618804,
+ "wcxb/default/type/listing": 0.6660601098285603,
+ "wcxb/default/type/product": 0.5220558838570278,
+ "wcxb/default/type/service": 0.8247964660072756,
+ "wcxb/main_content/overall": 0.4999264653091694,
+ "wcxb/main_content/type/article": 0.9687139672138977,
+ "wcxb/main_content/type/collection": 0.484765233729096,
+ "wcxb/main_content/type/documentation": 0.7157843169028515,
+ "wcxb/main_content/type/forum": 0.10889386123735083,
+ "wcxb/main_content/type/listing": 0.33261257051933946,
+ "wcxb/main_content/type/product": 0.4767832465487646,
+ "wcxb/main_content/type/service": 0.41193206101288565
+ },
+ "provenance": {
+ "binary_sha256": "7df6b8dee165fbb3b57b69392a1ac2604441feb6f19f4aa85bf17dbbe47d7ec8",
+ "build_profile": "release",
+ "command": [
+ "/workspace/markkit/target/public-benchmarks/bin/mira",
+ "run",
+ "--study-python",
+ "scripts/public_benchmarks/study.py",
+ "--max-concurrent",
+ "1",
+ "--max-retries",
+ "0",
+ "--timeout",
+ "300",
+ "--group-by",
+ "category",
+ "--format",
+ "json",
+ "--out",
+ "/workspace/markkit/work/ocrs/public/mira.json"
+ ],
+ "core_diff_sha256": "03ee134984a9996a4fc4352921c640d5d821acb6099a5f188a907a3721351443",
+ "cpu_count": 5,
+ "machine": "x86_64",
+ "mira_version": "mira 0.5.0",
+ "pins": {
+ "agentdocbench": "e97ccc4a2be9c9a71289c4e000e60e953daccaa3",
+ "mira": "v0.5.0",
+ "mira_linux_archive_sha256": "b69d1a9af34d83aae87aad2453cf642739de9e0859e043b2b6571839ef7462fc",
+ "mira_sdk": "334506ef19a1d975dace0f2c51092548a10c7b3e",
+ "samples_per_type": 10,
+ "seed": "markkit-public-pilot-v1:",
+ "wcxb": "c039d5ee9f5a3a984a0e167e63aacd04e76e78a9"
+ },
+ "platform": "Linux-6.18.55-x86_64-with-glibc2.41",
+ "python": "3.12.14",
+ "rustc_version": "rustc 1.99.1 (58cf8f9c3 2026-10-13)",
+ "source_dirty": true,
+ "source_revision": "7a4e62f0497deee864f39600e5a36a3d348260bf",
+ "timing_boundary": "median of 3 fresh CLI processes after 1 warm-up; sequential; includes startup"
+ },
+ "build_configuration": "Default CLI features; optional ocrs disabled. Backend comparison uses a separately built CLI with pdf-ocr-ocrs enabled."
+ },
+ "geometry_scope": "Authored model-backed fixture checks three known words in their independent expected source region. Public scans check geometry validity and cross-engine agreement; no ground-truth word boxes exist in this corpus.",
+ "dependency_provenance": {
+ "ocrs_owner": "robertknight",
+ "owner_public_name": "Robert Knight",
+ "owner_public_location": "London, United Kingdom",
+ "registry_owner_source": "https://crates.io/api/v1/crates/ocrs/owners",
+ "owner_source": "https://github.com/robertknight",
+ "finding": "No Russian organizational owner found in public records; does not establish every contributor's background or affiliations.",
+ "runtime": "Ocrs and RTen implement inference in Rust. Pure Rust OCR production dependency tree, with http_client disabled, adds no cc/cmake/native ML runtime dependency; Tesseract is not invoked on the ocrs path."
+ },
+ "interpretation": {
+ "default_backend": "Tesseract remains the default; ocrs is explicitly selected and optional at build time.",
+ "recommendation": "Use ocrs for a Rust-only OCR deployment when its language, accuracy, CPU and memory tradeoffs fit. Keep Tesseract as default for this scan corpus.",
+ "limits": [
+ "Only the configured default Latin model/eng selector is supported by this integration.",
+ "Combined local model file bytes are bounded; inference activation memory requires host quotas.",
+ "Deadlines are cooperative around non-interruptible RTen operations and between batches of 20 lines.",
+ "Model weights are separate CC-BY-SA 4.0 deployment assets and are not embedded in library/binding packages.",
+ "Same shared PDF rendering restrictions apply to both engines.",
+ "These three scanned documents are a small evaluation set, not a general OCR ranking."
+ ]
+ },
+ "regressed_cases": [
+ "scan-nasa-budget",
+ "scan-nasa-notes"
+ ],
+ "changed_source_sha256": {
+ ".github/workflows/ci.yml": "c406f40a0712ae1332fbe5673faa17337d38dc86cb31c630e02d1556cd1f29f2",
+ ".github/workflows/python.yml": "3eff41f9546a45d783194cf79a69506322ed00f7fa9d37de1ca63fb6cc725430",
+ "Cargo.lock": "f6a928ad608c83cf1de580b323a7eb9cbe5fc3f2d6a888e5cb754bd94803729e",
+ "Cargo.toml": "3dd578d6897285ad735650d56ab74afdede2b7cd96fb8f3ceb66ea6f1f592e1d",
+ "crates/markkit-cli/Cargo.toml": "2270315eda96b6b9858c775f8fa39d12dc09e28961e71923da523b9aa1bf3a16",
+ "crates/markkit-cli/src/main.rs": "eca24db09752599a5eb22c50866e5bcdb2f0f637e53a1d63bb91e6918e444f85",
+ "crates/markkit-cli/tests/cli_integration.rs": "6d654357450176589000bcd356720404307c59ca26faddfa47ca4f8310cef304",
+ "crates/markkit-eval/Cargo.toml": "903b131f5716708491924ea56af529e681a980c499223d909cab1b93e43fee7d",
+ "crates/markkit-eval/data/pdf-layout.json": "38f5ac20041441fad3f6efcbe5a2f8ca82d408339c953971ccf036ebf856d42b",
+ "crates/markkit-eval/src/lib.rs": "ee1e9ac8ec5113fbb2b0a2cea6948e07905d9cc0d021c49ee212fc3f51ddf843",
+ "crates/markkit-eval/src/main.rs": "feb406d2f02b3fc35efd7a0647780dc7c66656852da1219e24b17f848f70720e",
+ "crates/markkit-eval/src/pdf.rs": "43bc3ee3b2fbd358068361bdce611710859ada341a1c631d566f367ebe6d6319",
+ "crates/markkit-js/Cargo.toml": "91fe5bd89dff4161f5d4a0c4251f897c989e4040d532453d200837f212c611dc",
+ "crates/markkit-js/__test__/options.test.cjs": "2ed01b68f702eedb9f3ae1a11127d41e1ad5bbff60251f86ae1e34deb9907f9a",
+ "crates/markkit-js/src/lib.rs": "298880fbe8928703393fbd0f91c6e42985546aa42e6129aa250a21d500643dff",
+ "crates/markkit-python/Cargo.toml": "85c464e50e25902ec70d956cf883e877ab705637fc84c9214fd05d49989f7f82",
+ "crates/markkit-python/src/lib.rs": "a9a0f8e182986a6ac2fc9e8b5db2cbf4965c90fc7d353d0d4c19b9f05de8b248",
+ "crates/markkit-python/tests/test_documents.py": "55c8fa60dd6662f8fc21ddeef9e6097cf76e9c0c8599c604aa801fe378753571",
+ "crates/markkit-wasm/Cargo.toml": "6b59cdb571c528ce4f362e5d0713e2ef0aab29eb928cd44579855cef2c9095cc",
+ "crates/markkit-wasm/__test__/markkit-wasm.test.mjs": "6edd60dae6a6bc165b6c78e9ef1d5134b21a5ab10f4509fe5e0953468612209b",
+ "crates/markkit-wasm/js/index.d.ts": "3f6c9853e1dd753ef940fa667827bff52d34a2707526ac243e6360082528c404",
+ "crates/markkit-wasm/src/lib.rs": "85791162e8d7862019c1b7da621a28ee4b2bb7789fafa04ccac1004eb72ad1d3",
+ "crates/markkit/Cargo.toml": "a4781629501bfb9e8991c8ca5653e10945ebaa9a49d634f5cc2816dff3bae980",
+ "crates/markkit/src/config.rs": "3e90b014d24a086c45bbfcf237d23cf719f95648357dcd3091271a31d10bcb0a",
+ "crates/markkit/src/extract.rs": "87b796ff60a4f995e6c6c308e480d132281f53983b9a76a66da021b935e2ed5e",
+ "crates/markkit/src/ir.rs": "b7a121efe1e498697698176bf2b779631f76a5daf511aebd74b6fa97c0b368b2",
+ "crates/markkit/src/layout.rs": "1979d867083314b2d5550bfe80b2c1a85f7da3a3bc5056af13379080b40bb623",
+ "crates/markkit/src/lib.rs": "9340d6d23b82b95e179bc6a1d2c2d461793e91a2f0769a333ab948c988932b2b",
+ "crates/markkit/src/limits.rs": "a197331637943ef907791697f1cb1c3270e35a6b5f7aed8480464bd01b00e0cc",
+ "crates/markkit/src/pdf/analyze.rs": "f27c781852039c574b72a6cc71594ac04562d39134794311b97e8dfb6252aa4d",
+ "crates/markkit/src/pdf/font.rs": "32c2569858d14ffb1cc6caf6419d79c25dce9c57bb33e1741a8714a728fdf02e",
+ "crates/markkit/src/pdf/guard.rs": "489d3bf291c63656ecd0181318dd4fffd7ac300e89ad029428814f3f2660d07e",
+ "crates/markkit/src/pdf/layout.rs": "8c9be884482158a6832989dab75936381cea955419d122e710e5b195478e88c6",
+ "crates/markkit/src/pdf/mod.rs": "95c0f19f28f49569c7f554166b67b99aa335eadc88c129b140cc7a1a75a3ada2",
+ "crates/markkit/src/pdf/ocr.rs": "2a60a407cc0fedee60681c6844a32983c19ff36c24b6f6ec759322c066b58a67",
+ "crates/markkit/src/pdf/ocr/rust.rs": "9b0ac6113b8720a38efd20894197e3e1853ac26d191823e3f445774c0de2f442",
+ "crates/markkit/src/pdf/tags.rs": "9e925845654113cdc2fecdf5a96f55377c8a23bd8443f9b70494a7dbde71cced",
+ "crates/markkit/tests/pdf_layout.rs": "9db16686713b59ae187bd315c8c47416bd7f67925750f80011c88f6f6cd2964a",
+ "crates/markkit/tests/pdf_ocr.rs": "dfab4ee65512422496b87166b64b54a6f46d72c28ba65aa15256e9fbabb3807c",
+ "crates/markkit/tests/pdf_structure.rs": "fd941762bbee191421f3eb790906e25a9b7d6ce49e5f55f8641595a0a5c54ca8",
+ "schema/ir.schema.json": "3763054cb70c7ce75c86b0ec2a70948cbee17d2e60613256ec63d697e8fe1860",
+ "scripts/compare_pdf_ocr.py": "69bd7d19bda3389eeeea8cb66f5606c10f56ee117b8ed195eb7ab02bb46b30e6",
+ "scripts/ocr_models.json": "8f093a6cf9ffcfd9c175f004e51340f513208674fa15fe6762ec0c48b9701179",
+ "scripts/prepare_ocr_models.py": "0b9b6735696983d6934ad29aedd87003dfe025bf13406a93c9e067cff43996df",
+ "scripts/tests/test_compare_pdf_ocr.py": "ba19316f39f1a92edbe0c841979a77d04d43eb3da620e989a34cd4cfd31956c3",
+ "scripts/tests/test_prepare_ocr_models.py": "35706f5905e587b045241c7eed7e9a7246e102936543d7a1f02517fd0cbd8025"
+ }
+}
diff --git a/crates/markkit-eval/results/pdf-layout-2026-10-28.json b/crates/markkit-eval/results/pdf-layout-2026-10-28.json
new file mode 100644
index 0000000..02f2d7b
--- /dev/null
+++ b/crates/markkit-eval/results/pdf-layout-2026-10-28.json
@@ -0,0 +1,522 @@
+{
+ "date": "2026-10-28",
+ "scope": "PDF extraction: reading order, layout blocks/words/lines/cells, ruled/borderless/merged/wrapped tables, typography/lists, tagged roles/ActualText, repeated margin suppression, native OCR, all bindings",
+ "source_revision": "7a4e62f0497deee864f39600e5a36a3d348260bf",
+ "source_dirty": true,
+ "baseline_binary_sha256": "0544cae4e9baed8c01a677d0e96fd7ed6f5221f743e0feddd493a65a1c0ca9b4",
+ "candidate_binary_sha256": "d36d3fe77edad5907b0038cfca0ee8f6c35ef6ce97f25bf91dcfa66f17a20919",
+ "public_regression": {
+ "cases": 178,
+ "regressions": [],
+ "before": {
+ "wcxb/default/overall": 0.6770940834789322,
+ "wcxb/default/type/article": 0.8593022688301003,
+ "wcxb/default/type/collection": 0.5618264586879532,
+ "wcxb/default/type/documentation": 0.849698642479727,
+ "wcxb/default/type/forum": 0.4559187546618804,
+ "wcxb/default/type/listing": 0.6660601098285603,
+ "wcxb/default/type/product": 0.5220558838570278,
+ "wcxb/default/type/service": 0.8247964660072756,
+ "wcxb/main_content/overall": 0.4999264653091694,
+ "wcxb/main_content/type/article": 0.9687139672138977,
+ "wcxb/main_content/type/collection": 0.484765233729096,
+ "wcxb/main_content/type/documentation": 0.7157843169028515,
+ "wcxb/main_content/type/forum": 0.10889386123735083,
+ "wcxb/main_content/type/listing": 0.33261257051933946,
+ "wcxb/main_content/type/product": 0.4767832465487646,
+ "wcxb/main_content/type/service": 0.41193206101288565,
+ "agentdocbench/overall": 0.7520402777777778,
+ "agentdocbench/no_ocr": 0.8204075757575757,
+ "agentdocbench/category/paper": 0.8231999999999999,
+ "agentdocbench/category/two-column": 0.7465666666666667,
+ "agentdocbench/category/report": 0.87095,
+ "agentdocbench/category/financial": 0.423325,
+ "agentdocbench/category/form": 0.7486666666666667,
+ "agentdocbench/category/scanned": 0.0,
+ "agentdocbench/category/cjk": 0.749,
+ "agentdocbench/category/slides": 0.674775,
+ "agentdocbench/category/spreadsheet": 1.0,
+ "agentdocbench/category/docx": 0.9988666666666667,
+ "agentdocbench/category/epub": 0.9891333333333333,
+ "agentdocbench/category/html": 1.0,
+ "agentdocbench/format/pdf": 0.5835739130434783,
+ "agentdocbench/format/pptx": 0.9572,
+ "agentdocbench/format/csv": 1.0,
+ "agentdocbench/format/xlsx": 1.0,
+ "agentdocbench/format/docx": 0.9988666666666667,
+ "agentdocbench/format/epub": 0.9891333333333333,
+ "agentdocbench/format/html": 1.0
+ },
+ "after": {
+ "agentdocbench/category/cjk": 0.9335,
+ "agentdocbench/category/docx": 0.9988666666666667,
+ "agentdocbench/category/epub": 0.9891333333333333,
+ "agentdocbench/category/financial": 0.49095,
+ "agentdocbench/category/form": 0.8377666666666667,
+ "agentdocbench/category/html": 1.0,
+ "agentdocbench/category/paper": 0.8956333333333333,
+ "agentdocbench/category/report": 0.8705,
+ "agentdocbench/category/scanned": 0.0,
+ "agentdocbench/category/slides": 0.75135,
+ "agentdocbench/category/spreadsheet": 1.0,
+ "agentdocbench/category/two-column": 0.7586666666666667,
+ "agentdocbench/format/csv": 1.0,
+ "agentdocbench/format/docx": 0.9988666666666667,
+ "agentdocbench/format/epub": 0.9891333333333333,
+ "agentdocbench/format/html": 1.0,
+ "agentdocbench/format/pdf": 0.6553260869565217,
+ "agentdocbench/format/pptx": 0.9572,
+ "agentdocbench/format/xlsx": 1.0,
+ "agentdocbench/no_ocr": 0.8660333333333333,
+ "agentdocbench/overall": 0.7938638888888889,
+ "wcxb/default/overall": 0.6770940834789322,
+ "wcxb/default/type/article": 0.8593022688301003,
+ "wcxb/default/type/collection": 0.5618264586879532,
+ "wcxb/default/type/documentation": 0.849698642479727,
+ "wcxb/default/type/forum": 0.4559187546618804,
+ "wcxb/default/type/listing": 0.6660601098285603,
+ "wcxb/default/type/product": 0.5220558838570278,
+ "wcxb/default/type/service": 0.8247964660072756,
+ "wcxb/main_content/overall": 0.4999264653091694,
+ "wcxb/main_content/type/article": 0.9687139672138977,
+ "wcxb/main_content/type/collection": 0.484765233729096,
+ "wcxb/main_content/type/documentation": 0.7157843169028515,
+ "wcxb/main_content/type/forum": 0.10889386123735083,
+ "wcxb/main_content/type/listing": 0.33261257051933946,
+ "wcxb/main_content/type/product": 0.4767832465487646,
+ "wcxb/main_content/type/service": 0.41193206101288565
+ },
+ "pdf_before": 0.5835739130434783,
+ "pdf_after": 0.6553260869565217,
+ "provenance": {
+ "binary_sha256": "d36d3fe77edad5907b0038cfca0ee8f6c35ef6ce97f25bf91dcfa66f17a20919",
+ "build_profile": "release",
+ "command": [
+ "/workspace/markkit/target/public-benchmarks/bin/mira",
+ "run",
+ "--study-python",
+ "scripts/public_benchmarks/study.py",
+ "--max-concurrent",
+ "1",
+ "--max-retries",
+ "0",
+ "--timeout",
+ "300",
+ "--group-by",
+ "category",
+ "--format",
+ "json",
+ "--out",
+ "/workspace/markkit/work/public-final/mira.json"
+ ],
+ "core_diff_sha256": "24fd2301f0fe1e96c824b3e12caaac5464aca8fdd0ad5a102948e1f68c477399",
+ "cpu_count": 5,
+ "machine": "x86_64",
+ "mira_version": "mira 0.5.0",
+ "pins": {
+ "agentdocbench": "e97ccc4a2be9c9a71289c4e000e60e953daccaa3",
+ "mira": "v0.5.0",
+ "mira_linux_archive_sha256": "b69d1a9af34d83aae87aad2453cf642739de9e0859e043b2b6571839ef7462fc",
+ "mira_sdk": "334506ef19a1d975dace0f2c51092548a10c7b3e",
+ "samples_per_type": 10,
+ "seed": "markkit-public-pilot-v1:",
+ "wcxb": "c039d5ee9f5a3a984a0e167e63aacd04e76e78a9"
+ },
+ "platform": "Linux-6.18.55-x86_64-with-glibc2.41",
+ "python": "3.12.14",
+ "rustc_version": "rustc 1.99.1 (58cf8f9c3 2026-10-13)",
+ "source_dirty": true,
+ "source_revision": "7a4e62f0497deee864f39600e5a36a3d348260bf",
+ "timing_boundary": "median of 3 fresh CLI processes after 1 warm-up; sequential; includes startup"
+ }
+ },
+ "pdf_scenes": {
+ "cases": 40,
+ "samples": 20,
+ "passed_cases": 40,
+ "modes": {
+ "default": {
+ "cases": 20,
+ "metrics": {
+ "text_fidelity": {
+ "passed": 20,
+ "scored": 20,
+ "score": 1.0
+ },
+ "layout_geometry": {
+ "passed": 0,
+ "scored": 0,
+ "score": null
+ },
+ "markdown_parity": {
+ "passed": 20,
+ "scored": 20,
+ "score": 1.0
+ }
+ }
+ },
+ "layout": {
+ "cases": 20,
+ "metrics": {
+ "text_fidelity": {
+ "passed": 20,
+ "scored": 20,
+ "score": 1.0
+ },
+ "layout_geometry": {
+ "passed": 20,
+ "scored": 20,
+ "score": 1.0
+ },
+ "markdown_parity": {
+ "passed": 20,
+ "scored": 20,
+ "score": 1.0
+ }
+ }
+ }
+ },
+ "expected_exit_status": 0
+ },
+ "public_pdf_layout": {
+ "documents": 23,
+ "pages": 243,
+ "spans": 183244,
+ "blocks": 11264,
+ "cases": [
+ {
+ "id": "paper-evsched",
+ "pages": 23,
+ "spans": 12924,
+ "blocks": 310,
+ "tables": 14,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "paper-rankone",
+ "pages": 27,
+ "spans": 35955,
+ "blocks": 2302,
+ "tables": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "paper-ulam",
+ "pages": 11,
+ "spans": 21304,
+ "blocks": 287,
+ "tables": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "twocol-bygpt5",
+ "pages": 16,
+ "spans": 16572,
+ "blocks": 542,
+ "tables": 3,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "twocol-smile",
+ "pages": 13,
+ "spans": 11879,
+ "blocks": 733,
+ "tables": 9,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "twocol-uscore",
+ "pages": 17,
+ "spans": 16510,
+ "blocks": 502,
+ "tables": 13,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "report-crs-dod-approps",
+ "pages": 12,
+ "spans": 10842,
+ "blocks": 397,
+ "tables": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "report-plz",
+ "pages": 5,
+ "spans": 5502,
+ "blocks": 52,
+ "tables": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "fed-h8",
+ "pages": 22,
+ "spans": 10785,
+ "blocks": 2841,
+ "tables": 11,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "fin-bls-realer",
+ "pages": 5,
+ "spans": 6722,
+ "blocks": 571,
+ "tables": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "fin-census-income",
+ "pages": 10,
+ "spans": 7424,
+ "blocks": 743,
+ "tables": 6,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "invoice-borderless",
+ "pages": 2,
+ "spans": 113,
+ "blocks": 42,
+ "tables": 1,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "form-1040es",
+ "pages": 12,
+ "spans": 5334,
+ "blocks": 631,
+ "tables": 5,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "form-w4",
+ "pages": 4,
+ "spans": 1739,
+ "blocks": 189,
+ "tables": 11,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "irs-w9",
+ "pages": 6,
+ "spans": 1074,
+ "blocks": 199,
+ "tables": 2,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "scan-nasa-budget",
+ "pages": 3,
+ "spans": 0,
+ "blocks": 0,
+ "tables": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "scan-nasa-notes",
+ "pages": 3,
+ "spans": 0,
+ "blocks": 0,
+ "tables": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "scan-oklahoma-address",
+ "pages": 7,
+ "spans": 0,
+ "blocks": 0,
+ "tables": 0,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "cjk-jp-stat-elderly",
+ "pages": 4,
+ "spans": 2797,
+ "blocks": 190,
+ "tables": 6,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "cjk-tw-archives",
+ "pages": 4,
+ "spans": 2385,
+ "blocks": 15,
+ "tables": 3,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "cjk-tw-dgbas-cpi",
+ "pages": 6,
+ "spans": 3614,
+ "blocks": 368,
+ "tables": 1,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "slides-nasa-clarreo",
+ "pages": 15,
+ "spans": 5715,
+ "blocks": 160,
+ "tables": 3,
+ "markdown_unchanged": true
+ },
+ {
+ "id": "slides-nist-pqc-update",
+ "pages": 16,
+ "spans": 4054,
+ "blocks": 190,
+ "tables": 3,
+ "markdown_unchanged": true
+ }
+ ]
+ },
+ "ocr": {
+ "documents": 3,
+ "baseline_mean_document_score": 0.0,
+ "mean_document_score": 0.5233666666666666,
+ "cases": [
+ {
+ "id": "scan-nasa-budget",
+ "scores": {
+ "document_score": 0.353,
+ "sentences": 0.2,
+ "order": 0.6667,
+ "table_cells_f1": 0.1923
+ },
+ "seconds": 21.30685543400068,
+ "spans": 833,
+ "binary_sha256": "d36d3fe77edad5907b0038cfca0ee8f6c35ef6ce97f25bf91dcfa66f17a20919"
+ },
+ {
+ "id": "scan-nasa-notes",
+ "scores": {
+ "document_score": 0.3838,
+ "sentences": 0.3333333333333333,
+ "order": 0.8182,
+ "table_cells_f1": 0.0
+ },
+ "seconds": 6.632421336999869,
+ "spans": 767,
+ "binary_sha256": "d36d3fe77edad5907b0038cfca0ee8f6c35ef6ce97f25bf91dcfa66f17a20919"
+ },
+ {
+ "id": "scan-oklahoma-address",
+ "scores": {
+ "document_score": 0.8333,
+ "sentences": 0.6666666666666666,
+ "order": 1.0
+ },
+ "seconds": 33.16324986499967,
+ "spans": 3175,
+ "binary_sha256": "d36d3fe77edad5907b0038cfca0ee8f6c35ef6ce97f25bf91dcfa66f17a20919"
+ }
+ ],
+ "configuration": "--ocr --timeout 300 --json --layout; installed Poppler/Tesseract eng; 1800px grayscale; one OCR thread"
+ },
+ "validation": {
+ "workspace_all_features": {
+ "passed": 812,
+ "failed": 0,
+ "ignored": 1
+ },
+ "workspace_default": {
+ "passed": 810,
+ "failed": 0,
+ "ignored": 1
+ },
+ "core_no_default_features": {
+ "passed": 149,
+ "failed": 0,
+ "ignored": 0
+ },
+ "ocr_helpers_explicit": {
+ "passed": 2,
+ "failed": 0,
+ "ignored": 0
+ },
+ "node": {
+ "passed": 10,
+ "failed": 0
+ },
+ "wasm": {
+ "passed": 23,
+ "failed": 0
+ },
+ "python": {
+ "passed": 2,
+ "failed": 0
+ },
+ "clippy": "passed, workspace/all targets/all features and PDF-only no-default build, -D warnings",
+ "rustdoc": "passed with -D warnings",
+ "format": "passed",
+ "knowledge": "OKF v0.2 conformant",
+ "script_tests": {
+ "run": 34,
+ "skipped": 2,
+ "failed": 0
+ }
+ },
+ "local_test_environment": {
+ "python_library": "LIBRARY_PATH and LD_LIBRARY_PATH point to the installed bundled Python 3.12 library",
+ "storage": "CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 after discarding disposable debug caches; no source changes for the storage workaround"
+ },
+ "limits_and_scope": [
+ "Layout is optional; source runs remain in drawing order and semantic blocks use reading order.",
+ "Table inference is conservative; nonrectangular/irregular or ambiguous structures can remain text.",
+ "Native OCR is opt-in for textless pages; requires installed helpers/languages. WASM extracts text layers/layout/tables.",
+ "OCR helpers run without a shell/network, with private working directories, deadline/size checks and kill/reap on timeout. Host process isolation/memory quotas govern native decoders.",
+ "Geometry is approximate font em bounds or recognized OCR word bounds."
+ ],
+ "changed_source_sha256": {
+ "Cargo.lock": "0d236f92a2230972c197d11ff2498a42c058a69e855abdddcea96bf7b7e9e76c",
+ "crates/markkit-cli/Cargo.toml": "00ca964fa09dbb701e54dd43895927bd193877dccb85664352e6f15f68c61de0",
+ "crates/markkit-cli/src/main.rs": "7afe10c34394d332b051f59925b94581735de7504b5ca9ba83501b32789a2bd8",
+ "crates/markkit-cli/tests/cli_integration.rs": "7cecfb9131c1cc446bdc3438a448cd630b1b0d440e7fa26641dcb47177ce5aad",
+ "crates/markkit-eval/Cargo.toml": "903b131f5716708491924ea56af529e681a980c499223d909cab1b93e43fee7d",
+ "crates/markkit-eval/src/lib.rs": "ee1e9ac8ec5113fbb2b0a2cea6948e07905d9cc0d021c49ee212fc3f51ddf843",
+ "crates/markkit-eval/src/main.rs": "feb406d2f02b3fc35efd7a0647780dc7c66656852da1219e24b17f848f70720e",
+ "crates/markkit-js/Cargo.toml": "8c1465370c370169f46de4d689ee7a65d49cc2a6d021ae9f24df4b75aaa773e6",
+ "crates/markkit-js/__test__/options.test.cjs": "551806085f99d27176e798eeb224c6d99828fd446573ca2641b2896b7df5851f",
+ "crates/markkit-js/src/lib.rs": "d95f076585da9f14ef29d8e622ddb3566ce6db46655c7570208807bd6275b316",
+ "crates/markkit-python/Cargo.toml": "60a08f0895531f9f4e47b9df5058c1f48b202449256f587c6f2b802d3295b86f",
+ "crates/markkit-python/src/lib.rs": "bed7b5ba5ec910e4561117599f88e83a9ff054d6555cc15012e28d0c3548326c",
+ "crates/markkit-wasm/Cargo.toml": "6b59cdb571c528ce4f362e5d0713e2ef0aab29eb928cd44579855cef2c9095cc",
+ "crates/markkit-wasm/__test__/markkit-wasm.test.mjs": "6edd60dae6a6bc165b6c78e9ef1d5134b21a5ab10f4509fe5e0953468612209b",
+ "crates/markkit-wasm/js/index.d.ts": "3f6c9853e1dd753ef940fa667827bff52d34a2707526ac243e6360082528c404",
+ "crates/markkit-wasm/src/lib.rs": "85791162e8d7862019c1b7da621a28ee4b2bb7789fafa04ccac1004eb72ad1d3",
+ "crates/markkit/Cargo.toml": "a4b5c413cf6268a9d77061755b1ae434e5ce616838c396280359fd7c0eb1038f",
+ "crates/markkit/src/config.rs": "c478f07db574ac09174a5f38b631ae41dc406527686c7379726f7fc1ae4464b4",
+ "crates/markkit/src/extract.rs": "87b796ff60a4f995e6c6c308e480d132281f53983b9a76a66da021b935e2ed5e",
+ "crates/markkit/src/ir.rs": "b7a121efe1e498697698176bf2b779631f76a5daf511aebd74b6fa97c0b368b2",
+ "crates/markkit/src/lib.rs": "757a2db4474403bc2b86a4f005dcf542ffd7b6a76e7682343565d91dbae46361",
+ "crates/markkit/src/limits.rs": "a197331637943ef907791697f1cb1c3270e35a6b5f7aed8480464bd01b00e0cc",
+ "crates/markkit/src/pdf/font.rs": "32c2569858d14ffb1cc6caf6419d79c25dce9c57bb33e1741a8714a728fdf02e",
+ "crates/markkit/src/pdf/mod.rs": "0b6f4c278e0239164ba87e93608bf1944a03779d38cb38cbfe3a53be9b30a07f",
+ "schema/ir.schema.json": "3763054cb70c7ce75c86b0ec2a70948cbee17d2e60613256ec63d697e8fe1860",
+ "crates/markkit-eval/data/pdf-layout.json": "38f5ac20041441fad3f6efcbe5a2f8ca82d408339c953971ccf036ebf856d42b",
+ "crates/markkit-eval/src/pdf.rs": "43bc3ee3b2fbd358068361bdce611710859ada341a1c631d566f367ebe6d6319",
+ "crates/markkit-python/tests/test_documents.py": "8bad0e0698412f3f26c0af583b16e2dbfb23f04aeaf5c2892875f21183b1b243",
+ "crates/markkit/src/layout.rs": "1979d867083314b2d5550bfe80b2c1a85f7da3a3bc5056af13379080b40bb623",
+ "crates/markkit/src/pdf/analyze.rs": "f27c781852039c574b72a6cc71594ac04562d39134794311b97e8dfb6252aa4d",
+ "crates/markkit/src/pdf/layout.rs": "8c9be884482158a6832989dab75936381cea955419d122e710e5b195478e88c6",
+ "crates/markkit/src/pdf/ocr.rs": "193274365632f5ce4464a5d6e285a7f7f46ab532026c72697e6b0b8b3057763b",
+ "crates/markkit/src/pdf/tags.rs": "9e925845654113cdc2fecdf5a96f55377c8a23bd8443f9b70494a7dbde71cced",
+ "crates/markkit/tests/pdf_layout.rs": "9db16686713b59ae187bd315c8c47416bd7f67925750f80011c88f6f6cd2964a",
+ "crates/markkit/tests/pdf_ocr.rs": "b999410a30f5bb90613d58a6e34a2cdb0fc76d6ad67eaf0dcfb7174c7302a24e",
+ "crates/markkit/tests/pdf_structure.rs": "fd941762bbee191421f3eb790906e25a9b7d6ce49e5f55f8641595a0a5c54ca8"
+ },
+ "commands": [
+ "cargo test --workspace",
+ "cargo test --workspace --all-features",
+ "cargo test -p markkit --no-default-features",
+ "cargo test -p markkit --features pdf-ocr --test pdf_ocr -- --include-ignored",
+ "cargo clippy --workspace --all-targets --all-features -- -D warnings",
+ "cargo clippy -p markkit --no-default-features --features pdf --all-targets -- -D warnings",
+ "just eval-pdf --max-concurrent 1 --format json --out work/pdf-complete.json",
+ "target/public-benchmarks/venv/bin/python scripts/public_benchmarks/run.py --out work/public-final",
+ "target/public-benchmarks/venv/bin/python work/final_pdf_validation.py",
+ "node --test crates/markkit-wasm/__test__/*.test.mjs",
+ "cd crates/markkit-js && node --test __test__/*.test.cjs",
+ "python3 -m unittest discover -s crates/markkit-python/tests"
+ ]
+}
diff --git a/crates/markkit-eval/src/lib.rs b/crates/markkit-eval/src/lib.rs
index d408210..c66b4f7 100644
--- a/crates/markkit-eval/src/lib.rs
+++ b/crates/markkit-eval/src/lib.rs
@@ -12,5 +12,6 @@
pub mod checks;
pub mod dataset;
pub mod mira_study;
+pub mod pdf;
pub mod provider;
pub mod qa;
diff --git a/crates/markkit-eval/src/main.rs b/crates/markkit-eval/src/main.rs
index b1688a4..2f0385c 100644
--- a/crates/markkit-eval/src/main.rs
+++ b/crates/markkit-eval/src/main.rs
@@ -18,6 +18,11 @@ fn markkit_extract() -> Eval {
mira_study::extract_eval()
}
+#[eval]
+fn markkit_pdf() -> Eval {
+ markkit_eval::pdf::pdf_eval()
+}
+
#[eval]
fn markkit_qa() -> Eval {
markkit_eval::qa::qa_eval()
diff --git a/crates/markkit-eval/src/pdf.rs b/crates/markkit-eval/src/pdf.rs
new file mode 100644
index 0000000..35f408c
--- /dev/null
+++ b/crates/markkit-eval/src/pdf.rs
@@ -0,0 +1,263 @@
+//! PDF text, layout and table eval with generated, inspectable content streams.
+//!
+//! Decision: expected coordinates and text are authored in the dataset, not
+//! computed by the parser. Both modes measure ideal text fidelity; `layout`
+//! additionally measures geometry. Column/table cases check spatial reconstruction
+//! and table structure. Missing/failed conversions score zero, never N/A.
+
+use std::time::Instant;
+
+use markkit::{DocumentHint, Markkit};
+use mira::subject::subject_fn;
+use mira::{Eval, Sample, Score, Transcript};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+struct Fixture {
+ id: String,
+ category: String,
+ pages: Vec,
+ markdown: String,
+ layout: Value,
+}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+struct Page {
+ attrs: String,
+ content: String,
+}
+
+const DATA: &str = include_str!("../data/pdf-layout.json");
+
+fn fixtures() -> Vec {
+ serde_json::from_str(DATA).expect("tested embedded PDF dataset")
+}
+
+// Minimal valid PDF writer, independent of hayro/markkit internals. Helvetica
+// omits widths intentionally: its documented 0.5 em fallback makes scenes easy
+// to inspect. Each page may override inherited MediaBox / Rotate / CropBox.
+fn pdf(f: &Fixture) -> Vec {
+ let kids: Vec<_> = (0..f.pages.len())
+ .map(|i| format!("{} 0 R", 4 + i * 2))
+ .collect();
+ let mut objects = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ format!(
+ "<< /Type /Pages /Kids [{}] /Count {} /MediaBox [0 0 612 792] >>",
+ kids.join(" "),
+ f.pages.len()
+ ),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
+ ];
+ for (i, p) in f.pages.iter().enumerate() {
+ objects.push(format!("<< /Type /Page /Parent 2 0 R {} /Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R >>", p.attrs, 5 + i * 2));
+ objects.push(format!(
+ "<< /Length {} >>\nstream\n{}\nendstream",
+ p.content.len(),
+ p.content
+ ));
+ }
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = vec![0];
+ for (i, o) in objects.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len()).as_bytes());
+ for offset in &offsets[1..] {
+ out.extend(format!("{offset:010} 00000 n \n").as_bytes());
+ }
+ out.extend(
+ format!(
+ "trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
+ offsets.len()
+ )
+ .as_bytes(),
+ );
+ out
+}
+
+/// Match expected geometry fields with a 0.001 point tolerance. Array lengths
+/// must match, so extra/missing pages or text runs cannot improve the score.
+fn matches(expected: &Value, actual: &Value) -> bool {
+ match (expected, actual) {
+ (Value::Object(e), Value::Object(a)) => e
+ .iter()
+ .all(|(k, v)| a.get(k).is_some_and(|a| matches(v, a))),
+ (Value::Array(e), Value::Array(a)) => {
+ e.len() == a.len() && e.iter().zip(a).all(|(e, a)| matches(e, a))
+ }
+ (Value::Number(e), Value::Number(a)) => e
+ .as_f64()
+ .zip(a.as_f64())
+ .is_some_and(|(e, a)| (e - a).abs() < 0.001),
+ _ => expected == actual,
+ }
+}
+
+fn transcript(f: &Fixture, layout: bool) -> Transcript {
+ let bytes = pdf(f);
+ let start = Instant::now();
+ let result = Markkit::builder()
+ .pdf_layout(layout)
+ .build()
+ .extract_document_sync(&bytes, DocumentHint::default());
+ let elapsed = start.elapsed();
+ let e = match result {
+ Ok(e) => e,
+ Err(e) => return Transcript::failed(format!("PDF extraction failed: {e}")),
+ };
+ let mut t = Transcript::response(serde_json::to_string(&e).expect("serializable extraction"));
+ t.record_metric("extract_us", elapsed.as_micros() as f64);
+ t.record_metric("input_bytes", bytes.len() as f64);
+ t.record_metric("output_bytes", t.final_response.len() as f64);
+ t.record_metric("layout_requested", if layout { 1.0 } else { 0.0 });
+ t
+}
+
+fn score(name: &str, sample: &Sample, t: &Transcript) -> Score {
+ if t.errored_infra() {
+ return Score::na(name, "subject infra error");
+ }
+ let fixture = fixtures()
+ .into_iter()
+ .find(|f| f.id == sample.id)
+ .expect("sample in embedded dataset");
+ let actual: Value = serde_json::from_str(&t.final_response).unwrap_or(Value::Null);
+ if name == "layout_geometry"
+ && t.error.is_none()
+ && actual["markdown"].is_string()
+ && t.metrics.get("layout_requested") == Some(&0.0)
+ {
+ return Score::na(name, "layout capture disabled in default mode");
+ }
+ let passed = match name {
+ "text_fidelity" => actual["markdown"].as_str() == Some(fixture.markdown.as_str()),
+ "layout_geometry" => matches(&fixture.layout, &actual["layout"]),
+ "markdown_parity" => {
+ let plain = transcript(&fixture, false);
+ let plain: Value = serde_json::from_str(&plain.final_response).unwrap_or(Value::Null);
+ actual["markdown"].is_string() && actual["markdown"] == plain["markdown"]
+ }
+ _ => false,
+ };
+ Score {
+ scorer: name.into(),
+ value: if passed { 1.0 } else { 0.0 },
+ pass: passed,
+ na: false,
+ reason: if passed {
+ "matched authored expectation".into()
+ } else {
+ format!("{name} expectation failed for {}", fixture.id)
+ },
+ }
+}
+
+/// Offline Mira study: compare default extraction with optional layout capture.
+pub fn pdf_eval() -> Eval {
+ let mut b = Eval::new("markkit_pdf")
+ .describe("PDF text fidelity, positioned geometry, table structure and Markdown parity")
+ .subject(subject_fn(|sample: Sample, cx| async move {
+ let mode = cx.param("mode").unwrap_or("default");
+ if !["default", "layout"].contains(&mode) {
+ return Transcript::infra_error("unknown PDF mode");
+ }
+ let Some(f) = fixtures().into_iter().find(|f| f.id == sample.id) else {
+ return Transcript::infra_error("missing PDF fixture");
+ };
+ transcript(&f, mode == "layout")
+ }))
+ .axis("mode", ["default", "layout"])
+ .target(crate::mira_study::markkit_target())
+ .max_turns(1);
+ for name in ["text_fidelity", "layout_geometry", "markdown_parity"] {
+ b = b.scorer(mira::scorer::scorer(
+ name,
+ move |s: &Sample, t: &Transcript| score(name, s, t),
+ ));
+ }
+ for f in fixtures() {
+ b = b.add_sample(
+ Sample::new(&f.id, format!("PDF {} scene", f.category))
+ .tag("pdf")
+ .tag(&f.category)
+ .file("scene.json", serde_json::to_string_pretty(&f).unwrap()),
+ );
+ }
+ b.build()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn fixtures_are_valid_and_cover_geometry_and_known_gaps() {
+ let fs = fixtures();
+ assert!(fs.len() >= 10);
+ let ids: std::collections::HashSet<_> = fs.iter().map(|f| &f.id).collect();
+ assert_eq!(ids.len(), fs.len());
+ for category in ["rotation", "crop", "spacing", "columns", "table", "empty"] {
+ assert!(
+ fs.iter().any(|f| f.category == category),
+ "missing {category}"
+ );
+ }
+ let _ = pdf_eval();
+ for f in fs {
+ let t = transcript(&f, true);
+ assert!(t.error.is_none(), "{}: {:?}", f.id, t.error);
+ let actual: Value = serde_json::from_str(&t.final_response).unwrap();
+ assert!(
+ matches(&f.layout, &actual["layout"]),
+ "{} geometry mismatch: {}",
+ f.id,
+ actual["layout"]
+ );
+ }
+ }
+ #[test]
+ fn geometry_scorer_rejects_missing_extra_wrong_and_non_numeric_values() {
+ let expected =
+ serde_json::json!({"pages":[{"width":612.0,"spans":[{"text":"Hi","bbox":[1,2,3,4]}]}]});
+ assert!(matches(&expected, &expected));
+ assert!(!matches(&expected, &Value::Null));
+ for patch in [
+ serde_json::json!(null),
+ serde_json::json!("612"),
+ serde_json::json!(613),
+ ] {
+ let mut actual = expected.clone();
+ actual["pages"][0]["width"] = patch;
+ assert!(!matches(&expected, &actual));
+ }
+ let mut actual = expected.clone();
+ actual["pages"][0]["spans"] = serde_json::json!([]);
+ assert!(!matches(&expected, &actual));
+ let mut actual = expected.clone();
+ actual["pages"].as_array_mut().unwrap().push(Value::Null);
+ assert!(!matches(&expected, &actual));
+ }
+ #[test]
+ fn extraction_failure_is_scored_zero() {
+ let sample = Sample::new(&fixtures()[0].id, "failure");
+ for name in ["text_fidelity", "layout_geometry", "markdown_parity"] {
+ let s = score(name, &sample, &Transcript::failed("malformed"));
+ assert!(!s.na && !s.pass && s.value == 0.0);
+ }
+ }
+
+ #[test]
+ fn disabled_layout_is_na_but_missing_requested_geometry_scores_zero() {
+ let f = &fixtures()[0];
+ let sample = Sample::new(&f.id, "optional geometry");
+ assert!(score("layout_geometry", &sample, &transcript(f, false)).na);
+ let mut requested = transcript(f, true);
+ let mut actual: Value = serde_json::from_str(&requested.final_response).unwrap();
+ actual.as_object_mut().unwrap().remove("layout");
+ requested.final_response = actual.to_string();
+ let s = score("layout_geometry", &sample, &requested);
+ assert!(!s.na && !s.pass && s.value == 0.0);
+ }
+}
diff --git a/crates/markkit-js/Cargo.toml b/crates/markkit-js/Cargo.toml
index cf99468..4c4147c 100644
--- a/crates/markkit-js/Cargo.toml
+++ b/crates/markkit-js/Cargo.toml
@@ -13,8 +13,11 @@ description = "Node.js bindings for Markkit markdown extraction via NAPI-RS"
crate-type = ["cdylib"]
doc = false
+[features]
+pdf-ocr-ocrs = ["markkit/pdf-ocr-ocrs"]
+
[dependencies]
-markkit = { path = "../markkit", features = ["all_formats", "http_client"] }
+markkit = { path = "../markkit", features = ["all_formats", "pdf-ocr", "http_client"] }
napi.workspace = true
napi-derive.workspace = true
diff --git a/crates/markkit-js/README.md b/crates/markkit-js/README.md
index 3014518..02491d7 100644
--- a/crates/markkit-js/README.md
+++ b/crates/markkit-js/README.md
@@ -1,6 +1,6 @@
# @everruns/markkit
-Native Node.js bindings for Markkit: HTML, DOCX and spreadsheets to Markdown.
+Native Node.js bindings for Markkit: HTML, PDF, DOCX and spreadsheets to Markdown.
This npm release supports **Linux x64 with glibc**. It does not include binaries for
macOS, Windows, ARM64 or musl Linux. Other platforms require a separate future
@@ -28,17 +28,61 @@ await extractHtml(html, { includeSelectors: ['article'], excludeSelectors: ['.ad
// URLs: non-public addresses are blocked by default.
await extractUrl('https://docs.example.com/page', { allowedHosts: ['docs.example.com'], maxRedirects: 2 });
-// Documents with metadata (HTML, DOCX, PPTX, XLSX/XLSB/XLS/ODS). `warnings` are `{ code, detail }`.
+// Documents with metadata (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS). `warnings` are `{ code, detail }`.
const doc = extractDocumentSync(fs.readFileSync('report.docx'), { fileName: 'report.docx' }, { maxSheetRows: 500 });
console.log(doc.title, doc.format, doc.warnings, doc.markdown);
```
Fields: `maxInputSize`, `maxOutputSize`, `timeout` (seconds, covers fetch, parsing and
rendering; `maxProcessingSecs` is the whole-second form), `maxNodes`, `maxNestingDepth`,
-`baseUrl`, `mainContent`, `preserveLinks`, `preserveImages`,
+`baseUrl`, `mainContent`, `pdfLayout`, `preserveLinks`, `preserveImages`,
`includeSelectors`, `excludeSelectors` (added to the defaults), `maxArchiveEntries`,
`maxArchiveEntrySize`, `maxArchiveTotalSize`, `maxSheetRows`, `maxSheetCols`,
`maxSpreadsheetCells`, `includeHiddenSheets`, `allowPrivateNetworks` (trusted URLs only),
`allowedHosts`, `maxRedirects`, `maxPdfPages`, `maxPdfStreamSize`, `maxPdfDecodedSize`.
For browsers and edge runtimes use [`@everruns/markkit-wasm`](https://www.npmjs.com/package/@everruns/markkit-wasm).
+
+## PDF layout and tables
+
+PDF text extraction reconstructs columns, ruled and conservative borderless tables,
+including merged and wrapped cells. Tagged headings/ActualText, bold/italic and
+numbered lists are preserved. Set `pdfRemoveRepeatedHeaders: true` to suppress
+repeated margin headers/footers.
+
+`pdfLayout: true` adds `layout: { version: 1, pages }` to document results. Source
+`spans` retain drawing order and include glyph-positioned `words`. Semantic `blocks`
+are in reading order, with `id`, `kind`, `bbox`, `spanIds`, `lines` and table `cells`.
+Cells have row/column positions, rowspan/colspan and source references. Coordinates
+are display points from the cropped/rotated top-left, x right and y down. Font
+bounds approximate an em box. Layout is optional and does not change Markdown.
+
+Node and WASM use camelCase keys. `maxNodes` bounds primitives and geometry;
+`maxPdfDecodedSize` bounds retained geometry and analysis work; table row/column/
+grid limits apply. `maxOutputSize` independently bounds compact layout JSON and
+Markdown, excluding JSON wrapper/pretty-print overhead.
+
+Native OCR: install Tesseract plus language data. Use
+`{ pdfOcr: true, pdfOcrLanguage: "eng", timeout: 300 }` on `extractDocumentSync` /
+`extractDocument`. Only textless pages are recognized. OCR word bounds are returned
+in layout; helpers use private files, bounded dimensions/output and the deadline.
+
+## Optional Rust OCR backend
+
+Source builds can enable `markkit-js`'s Cargo feature `pdf-ocr-ocrs`. Default npm
+builds retain Tesseract. Prepare the pinned models from the repository with
+`python3 scripts/prepare_ocr_models.py`, then configure:
+
+```js
+extractDocumentSync(bytes, { fileName: "scan.pdf" }, {
+ pdfOcr: true, pdfOcrBackend: "ocrs", timeout: 300,
+ pdfOcrDetectionModel: "/models/text-detection.onnx",
+ pdfOcrRecognitionModel: "/models/text-recognition.onnx"
+});
+```
+
+Models load from explicit local paths only. The pinned weights use CC-BY-SA 4.0
+and carry attribution in the setup script's NOTICE.json. The current Latin
+model accepts `pdfOcrLanguage: "eng"`. Missing build features/models and other
+languages fail explicitly. Model files and inference need trusted deployment
+configuration and host CPU/memory quotas; inference deadlines are cooperative.
diff --git a/crates/markkit-js/__test__/options.test.cjs b/crates/markkit-js/__test__/options.test.cjs
index 35eb814..12fe0b1 100644
--- a/crates/markkit-js/__test__/options.test.cjs
+++ b/crates/markkit-js/__test__/options.test.cjs
@@ -86,3 +86,39 @@ test("extractUrl blocks private networks unless allowed", async () => {
srv.close();
}
});
+
+test("PDF source geometry is opt-in and obeys layout limits", () => {
+ const content = "BT /F1 10 Tf 10 100 Td (Hi) Tj ET";
+ const source = `%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj
+4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
+5 0 obj << /Length ${content.length} >> stream
+${content}
+endstream endobj
+trailer << /Root 1 0 R >>
+%%EOF`;
+ const data = Buffer.from(source);
+ const hint = { fileName: "a.pdf" };
+ const plain = m.extractDocumentSync(data, hint);
+ assert.equal(plain.markdown, "Hi\n");
+ assert.ok(plain.layout == null);
+ const result = m.extractDocumentSync(data, hint, { pdfLayout: true });
+ assert.equal(result.markdown, plain.markdown);
+ assert.equal(result.layout.pages[0].width, 300);
+ assert.equal(result.layout.pages[0].blocks[0].kind, "paragraph");
+ assert.deepEqual(result.layout.pages[0].blocks[0].spanIds, [0]);
+ assert.equal(result.layout.pages[0].spans[0].words[0].text, "Hi");
+ assert.equal(result.layout.pages[0].height, 400);
+ assert.equal(result.layout.pages[0].spans[0].text, "Hi");
+ assert.deepEqual(result.layout.pages[0].spans[0].baseline, [[10, 300], [20, 300]]);
+ assert.throws(() => m.extractDocumentSync(data, hint, { pdfLayout: true, maxNodes: 1 }), /max_nodes/);
+ assert.throws(() => m.extractDocumentSync(data, hint, { pdfLayout: true, maxOutputSize: 50 }), /max_output_size/);
+});
+
+
+test("OCR backend configuration validates names", () => {
+ assert.throws(() => m.extractHtmlSync("Hi
", { pdfOcrBackend: "unknown" }), /tesseract or ocrs/);
+ assert.equal(m.extractHtmlSync("Hi
", { pdfOcrBackend: "ocrs", pdfOcrDetectionModel: "unused.onnx", pdfOcrRecognitionModel: "unused.onnx" }), "Hi\n");
+});
diff --git a/crates/markkit-js/src/lib.rs b/crates/markkit-js/src/lib.rs
index 5116dce..d6e93ce 100644
--- a/crates/markkit-js/src/lib.rs
+++ b/crates/markkit-js/src/lib.rs
@@ -33,6 +33,14 @@ pub struct MarkkitOptions {
pub base_url: Option,
/// Keep only the detected main content (article body) of HTML pages.
pub main_content: Option,
+ /// Retain PDF page dimensions and positioned text runs in `Extraction.layout`.
+ pub pdf_layout: Option,
+ pub pdf_remove_repeated_headers: Option,
+ pub pdf_ocr: Option,
+ pub pdf_ocr_language: Option,
+ pub pdf_ocr_backend: Option,
+ pub pdf_ocr_detection_model: Option,
+ pub pdf_ocr_recognition_model: Option,
pub preserve_links: Option,
pub preserve_images: Option,
/// CSS selectors to include (empty = whole document).
@@ -100,6 +108,7 @@ pub struct Warning {
/// Markdown plus metadata.
#[napi(object)]
pub struct Extraction {
+ pub layout: Option,
pub markdown: String,
pub title: Option,
/// Detected format (`html`, `docx`, `pptx`, `xlsx`, `xlsb`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`, `ipynb`, `eml`, `odt`, `epub`).
@@ -109,6 +118,75 @@ pub struct Extraction {
pub metadata: Metadata,
}
+/// PDF source geometry; coordinates are display points, x right and y down.
+#[napi(object)]
+pub struct PdfLayout {
+ pub version: u32,
+ pub pages: Vec,
+}
+
+#[napi(object)]
+pub struct PdfPageLayout {
+ pub page: u32,
+ pub width: f64,
+ pub height: f64,
+ pub rotation: u32,
+ pub spans: Vec,
+ pub blocks: Vec,
+}
+
+#[napi(object)]
+pub struct PdfTextSpan {
+ pub id: u32,
+ pub text: String,
+ /// Approximate em bounds: [left, top, right, bottom].
+ pub bbox: Vec,
+ pub baseline: Vec>,
+ pub font: Option,
+ pub size: f64,
+ pub angle: f64,
+ pub words: Vec,
+ pub bold: bool,
+ pub italic: bool,
+ pub role: Option,
+ pub ocr: bool,
+}
+
+#[napi(object)]
+pub struct PdfWord {
+ pub text: String,
+ pub bbox: Vec,
+ pub baseline: Vec>,
+}
+#[napi(object)]
+pub struct PdfLine {
+ pub bbox: Vec,
+ pub span_ids: Vec,
+}
+#[napi(object)]
+pub struct PdfCell {
+ pub row: u32,
+ pub column: u32,
+ pub rowspan: u32,
+ pub colspan: u32,
+ pub bbox: Vec,
+ pub span_ids: Vec,
+}
+#[napi(object)]
+pub struct PdfBlock {
+ pub id: u32,
+ pub kind: String,
+ pub bbox: Vec,
+ pub span_ids: Vec,
+ pub lines: Vec,
+ pub cells: Vec,
+}
+fn js_ids(ids: Vec) -> Vec {
+ ids.into_iter()
+ .map(|n| u32::try_from(n).unwrap_or(u32::MAX))
+ .collect()
+}
+
fn invalid(field: &str) -> napi::Error {
napi::Error::from_reason(format!(
"invalid options: `{field}` must be a non-negative integer"
@@ -164,6 +242,27 @@ fn markkit(options: Option) -> napi::Result {
if let Some(v) = o.main_content {
b = b.main_content(v);
}
+ if let Some(v) = o.pdf_remove_repeated_headers {
+ b = b.pdf_remove_repeated_headers(v);
+ }
+ if let Some(v) = o.pdf_ocr {
+ b = b.pdf_ocr(v);
+ }
+ if let Some(v) = o.pdf_ocr_backend {
+ b = b.pdf_ocr_backend(v.parse().map_err(napi::Error::from_reason)?);
+ }
+ if let Some(v) = o.pdf_ocr_detection_model {
+ b = b.pdf_ocr_detection_model(v);
+ }
+ if let Some(v) = o.pdf_ocr_recognition_model {
+ b = b.pdf_ocr_recognition_model(v);
+ }
+ if let Some(v) = o.pdf_ocr_language {
+ b = b.pdf_ocr_language(v);
+ }
+ if let Some(v) = o.pdf_layout {
+ b = b.pdf_layout(v);
+ }
if let Some(v) = o.preserve_links {
b = b.preserve_links(v);
}
@@ -228,6 +327,75 @@ fn count(v: Option) -> Option {
fn to_js(e: markkit::Extraction) -> Extraction {
Extraction {
+ layout: e.layout.map(|l| PdfLayout {
+ version: l.version,
+ pages: l
+ .pages
+ .into_iter()
+ .map(|p| PdfPageLayout {
+ page: p.page,
+ width: p.width,
+ height: p.height,
+ rotation: p.rotation.into(),
+ blocks: p
+ .blocks
+ .into_iter()
+ .map(|b| PdfBlock {
+ id: b.id as u32,
+ kind: b.kind,
+ bbox: b.bbox.to_vec(),
+ span_ids: js_ids(b.span_ids),
+ lines: b
+ .lines
+ .into_iter()
+ .map(|l| PdfLine {
+ bbox: l.bbox.to_vec(),
+ span_ids: js_ids(l.span_ids),
+ })
+ .collect(),
+ cells: b
+ .cells
+ .into_iter()
+ .map(|c| PdfCell {
+ row: c.row as u32,
+ column: c.column as u32,
+ rowspan: c.rowspan,
+ colspan: c.colspan,
+ bbox: c.bbox.to_vec(),
+ span_ids: js_ids(c.span_ids),
+ })
+ .collect(),
+ })
+ .collect(),
+ spans: p
+ .spans
+ .into_iter()
+ .map(|s| PdfTextSpan {
+ id: u32::try_from(s.id).unwrap_or(u32::MAX),
+ text: s.text,
+ bbox: s.bbox.to_vec(),
+ baseline: s.baseline.into_iter().map(|p| p.to_vec()).collect(),
+ font: s.font,
+ size: s.size,
+ angle: s.angle,
+ words: s
+ .words
+ .into_iter()
+ .map(|w| PdfWord {
+ text: w.text,
+ bbox: w.bbox.to_vec(),
+ baseline: w.baseline.into_iter().map(|p| p.to_vec()).collect(),
+ })
+ .collect(),
+ bold: s.bold,
+ italic: s.italic,
+ role: s.role,
+ ocr: s.ocr,
+ })
+ .collect(),
+ })
+ .collect(),
+ }),
markdown: e.markdown,
title: e.title,
format: e.format.as_str().to_string(),
diff --git a/crates/markkit-python/Cargo.toml b/crates/markkit-python/Cargo.toml
index 1d7300e..7b4d8d1 100644
--- a/crates/markkit-python/Cargo.toml
+++ b/crates/markkit-python/Cargo.toml
@@ -14,7 +14,11 @@ name = "markkit_python"
crate-type = ["cdylib"]
doc = false
+[features]
+pdf-ocr-ocrs = ["markkit/pdf-ocr-ocrs"]
+
[dependencies]
-markkit = { path = "../markkit", features = ["all_formats", "http_client"] }
+markkit = { path = "../markkit", features = ["all_formats", "pdf-ocr", "http_client"] }
+serde_json.workspace = true
pyo3.workspace = true
pyo3-async-runtimes.workspace = true
diff --git a/crates/markkit-python/README.md b/crates/markkit-python/README.md
index 38e2621..afbcccf 100644
--- a/crates/markkit-python/README.md
+++ b/crates/markkit-python/README.md
@@ -37,3 +37,49 @@ PyPI API token. A dry run builds and verifies distributions without publishing.
Python packaging is configured by the workspace-root `pyproject.toml`. Source
archives retain the complete Cargo workspace and its unchanged security lockfile.
+
+## Structured documents, PDF layout and OCR
+
+```python
+from markkit import extract_document_sync, Limits
+result = extract_document_sync(
+ pdf_bytes, file_name="report.pdf", pdf_layout=True,
+ pdf_remove_repeated_headers=True,
+)
+print(result["markdown"], result["stats"]["tables"])
+print(result["layout"]["pages"][0]["blocks"])
+```
+
+PDF extraction reconstructs reading order, columns and ruled/borderless tables,
+with merged/wrapped cells, emphasis, numbered lists and tagged headings/ActualText.
+Layout includes source runs/words and semantic block/line/cell geometry. Keys are
+snake_case; coordinates are display points from the cropped/rotated top-left.
+
+For image-only pages, install Tesseract and your language data:
+`extract_document_sync(pdf_bytes, file_name="scan.pdf", pdf_ocr=True,
+pdf_ocr_language="eng", pdf_layout=True, limits=Limits(timeout=300))`.
+OCR is opt-in and only processes pages without text. Resource limits also bound
+geometry, grid expansion, raster dimensions/helper files and process deadlines.
+
+## Optional Rust OCR backend
+
+Source builds can enable Cargo feature `pdf-ocr-ocrs` (for example,
+`maturin build --features pdf-ocr-ocrs`). Default wheels retain Tesseract.
+Prepare the repository's pinned model pair with
+`python3 scripts/prepare_ocr_models.py`, then use:
+
+```python
+markkit.extract_document_sync(
+ data, file_name="scan.pdf", pdf_ocr=True, pdf_ocr_backend="ocrs",
+ pdf_ocr_detection_model="/models/text-detection.onnx",
+ pdf_ocr_recognition_model="/models/text-recognition.onnx",
+ limits=markkit.Limits(timeout=300),
+)
+```
+
+Extraction never downloads models. The pinned weights are CC-BY-SA 4.0 assets
+with attribution in the setup script's NOTICE.json. The current Latin model
+accepts `pdf_ocr_language="eng"` only. Model files are trusted deployment assets;
+inference deadlines are cooperative and hard CPU/memory limits require host
+quotas. Invalid backend names raise ValueError; missing features/models and
+unsupported languages raise RuntimeError.
diff --git a/crates/markkit-python/src/lib.rs b/crates/markkit-python/src/lib.rs
index 71b28a9..42ee8c8 100644
--- a/crates/markkit-python/src/lib.rs
+++ b/crates/markkit-python/src/lib.rs
@@ -251,10 +251,56 @@ fn extract_bytes<'py>(
})
}
+/// Extract document bytes to a Python dictionary containing Markdown, metadata
+/// and optional PDF layout. OCR is local and opt-in; helpers must be installed.
+#[pyfunction]
+#[pyo3(signature = (data, *, file_name=None, content_type=None, pdf_layout=false, pdf_remove_repeated_headers=false, pdf_ocr=false, pdf_ocr_language="eng", pdf_ocr_backend="tesseract", pdf_ocr_detection_model=None, pdf_ocr_recognition_model=None, limits=None))]
+#[allow(clippy::too_many_arguments)]
+fn extract_document_sync<'py>(
+ py: Python<'py>,
+ data: Vec,
+ file_name: Option,
+ content_type: Option,
+ pdf_layout: bool,
+ pdf_remove_repeated_headers: bool,
+ pdf_ocr: bool,
+ pdf_ocr_language: &str,
+ pdf_ocr_backend: &str,
+ pdf_ocr_detection_model: Option,
+ pdf_ocr_recognition_model: Option,
+ limits: Option>,
+) -> PyResult> {
+ let mut config = builder(limits.as_deref())
+ .pdf_layout(pdf_layout)
+ .pdf_remove_repeated_headers(pdf_remove_repeated_headers)
+ .pdf_ocr(pdf_ocr)
+ .pdf_ocr_language(pdf_ocr_language)
+ .pdf_ocr_backend(pdf_ocr_backend.parse().map_err(PyValueError::new_err)?);
+ if let Some(path) = pdf_ocr_detection_model {
+ config = config.pdf_ocr_detection_model(path);
+ }
+ if let Some(path) = pdf_ocr_recognition_model {
+ config = config.pdf_ocr_recognition_model(path);
+ }
+ let e = config
+ .build()
+ .extract_document_sync(
+ &data,
+ markkit::DocumentHint {
+ file_name: file_name.as_deref(),
+ content_type: content_type.as_deref(),
+ },
+ )
+ .map_err(err)?;
+ let json = serde_json::to_string(&e).map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
+ py.import("json")?.call_method1("loads", (json,))
+}
+
#[pymodule]
#[pyo3(name = "markkit")]
fn markkit_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::()?;
+ m.add_function(wrap_pyfunction!(extract_document_sync, m)?)?;
m.add_function(wrap_pyfunction!(extract_html, m)?)?;
m.add_function(wrap_pyfunction!(extract_html_sync, m)?)?;
m.add_function(wrap_pyfunction!(extract_html_sync_with_options, m)?)?;
diff --git a/crates/markkit-python/tests/test_documents.py b/crates/markkit-python/tests/test_documents.py
new file mode 100644
index 0000000..0937094
--- /dev/null
+++ b/crates/markkit-python/tests/test_documents.py
@@ -0,0 +1,43 @@
+"""Structured byte extraction, including PDF tables and source geometry."""
+import unittest
+import markkit
+
+
+def pdf(content):
+ return ("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
+ "2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"
+ "3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n"
+ "4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n"
+ f"5 0 obj << /Length {len(content)} >> stream\n{content}\nendstream endobj\n"
+ "trailer << /Root 1 0 R >>\n%%EOF").encode()
+
+
+class Documents(unittest.TestCase):
+ def test_pdf_layout_and_limits(self):
+ data = pdf("BT /F1 10 Tf 10 100 Td (Hi) Tj ET")
+ plain = markkit.extract_document_sync(data, file_name="a.pdf")
+ self.assertNotIn("layout", plain)
+ e = markkit.extract_document_sync(data, file_name="a.pdf", pdf_layout=True)
+ self.assertEqual(e["markdown"], plain["markdown"])
+ self.assertEqual(e["layout"]["pages"][0]["blocks"][0]["kind"], "paragraph")
+ self.assertEqual(e["layout"]["pages"][0]["spans"][0]["baseline"], [[10, 300], [20, 300]])
+ with self.assertRaisesRegex(RuntimeError, "max_nodes"):
+ markkit.extract_document_sync(data, file_name="a.pdf", pdf_layout=True, limits=markkit.Limits(max_nodes=1))
+
+ def test_ocr_backend_validation_and_lazy_models(self):
+ data = pdf("BT /F1 10 Tf 10 100 Td (Hi) Tj ET")
+ with self.assertRaisesRegex(ValueError, "tesseract or ocrs"):
+ markkit.extract_document_sync(data, pdf_ocr_backend="unknown")
+ result = markkit.extract_document_sync(data, pdf_ocr=True, pdf_ocr_backend="ocrs", pdf_ocr_detection_model="unused.onnx", pdf_ocr_recognition_model="unused.onnx")
+ self.assertEqual(result["markdown"], "Hi\n")
+
+ def test_borderless_table(self):
+ content = "\n".join(f"BT /F1 10 Tf 10 {y} Td ({a}) Tj ET BT /F1 10 Tf 150 {y} Td ({b}) Tj ET" for y, a, b in [(300, "Item", "Qty"), (280, "Apples", "3"), (260, "Pears", "2")])
+ e = markkit.extract_document_sync(pdf(content), file_name="a.pdf", pdf_layout=True)
+ self.assertEqual(e["stats"]["tables"], 1)
+ self.assertIn("| Apples | 3 |", e["markdown"])
+ self.assertEqual(e["layout"]["pages"][0]["blocks"][0]["kind"], "table")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/crates/markkit-wasm/Cargo.toml b/crates/markkit-wasm/Cargo.toml
index 857ae55..4c6d45e 100644
--- a/crates/markkit-wasm/Cargo.toml
+++ b/crates/markkit-wasm/Cargo.toml
@@ -5,7 +5,7 @@
# SharedArrayBuffer and no COOP/COEP headers, so it runs in browsers, edge
# workers (Cloudflare, Vercel, Deno Deploy), Node, Deno and Bun.
#
-# Feature surface: html, docx, spreadsheet, tool. `http_client` is excluded:
+# Feature surface includes pure-Rust PDF text/layout. `http_client` is excluded:
# reqwest needs sockets; JS hosts fetch themselves and pass bytes in, which also
# keeps SSRF policy with the host. See knowledge/runtimes/wasm-package.md.
@@ -26,6 +26,7 @@ doc = false
[dependencies]
markkit = { path = "../markkit", default-features = false, features = [
"html",
+ "pdf",
"docx",
"spreadsheet",
"markdown",
diff --git a/crates/markkit-wasm/README.md b/crates/markkit-wasm/README.md
index 05dc404..6936b4f 100644
--- a/crates/markkit-wasm/README.md
+++ b/crates/markkit-wasm/README.md
@@ -1,6 +1,6 @@
# @everruns/markkit-wasm
-Markkit compiled to WebAssembly: extract clean Markdown from HTML, DOCX,
+Markkit compiled to WebAssembly: extract clean Markdown from HTML, PDF text/layout, DOCX,
spreadsheets (XLSX/XLSM, XLS, ODS), CSV/TSV, Jupyter notebooks, plain text and
Markdown in the **browser and any JavaScript runtime**:
edge workers (Cloudflare Workers, Vercel Edge, Deno Deploy), Node, Deno and Bun.
@@ -67,3 +67,25 @@ try {
## License
MIT
+
+## PDF layout and tables
+
+PDF text extraction reconstructs columns, ruled and conservative borderless tables,
+including merged and wrapped cells. Tagged headings/ActualText, bold/italic and
+numbered lists are preserved. Set `pdfRemoveRepeatedHeaders: true` to suppress
+repeated margin headers/footers.
+
+`pdfLayout: true` adds `layout: { version: 1, pages }` to document results. Source
+`spans` retain drawing order and include glyph-positioned `words`. Semantic `blocks`
+are in reading order, with `id`, `kind`, `bbox`, `spanIds`, `lines` and table `cells`.
+Cells have row/column positions, rowspan/colspan and source references. Coordinates
+are display points from the cropped/rotated top-left, x right and y down. Font
+bounds approximate an em box. Layout is optional and does not change Markdown.
+
+Node and WASM use camelCase keys. `maxNodes` bounds primitives and geometry;
+`maxPdfDecodedSize` bounds retained geometry and analysis work; table row/column/
+grid limits apply. `maxOutputSize` independently bounds compact layout JSON and
+Markdown, excluding JSON wrapper/pretty-print overhead.
+
+WASM reads existing PDF text layers. Native process OCR runs through the Rust,
+Node or Python packages with installed Tesseract and language data.
diff --git a/crates/markkit-wasm/__test__/markkit-wasm.test.mjs b/crates/markkit-wasm/__test__/markkit-wasm.test.mjs
index ccb3f2e..f2e733e 100644
--- a/crates/markkit-wasm/__test__/markkit-wasm.test.mjs
+++ b/crates/markkit-wasm/__test__/markkit-wasm.test.mjs
@@ -105,7 +105,8 @@ test("extractDocument reads Jupyter notebooks", () => {
test("extractBytes and unsupported formats", () => {
const mk = new Markkit();
assert.equal(mk.extractBytes(new TextEncoder().encode("x
"), "text/html"), "x\n");
- assert.throws(() => mk.extractDocument(new TextEncoder().encode("%PDF-1.7"), { fileName: "a.pdf" }), /Unsupported/);
+ assert.throws(() => mk.extractDocument(new TextEncoder().encode("%PDF-1.7"), { fileName: "a.pdf" }), /PDF extraction error/);
+ assert.throws(() => mk.extractDocument(new Uint8Array([0]), { fileName: "a.doc" }), /Unsupported/);
});
test("tool metadata and definition", () => {
@@ -182,3 +183,33 @@ test("tool converts base64 DOCX", async () => {
assert.equal(out.format, "docx");
assert.match(out.markdown, /From tool/);
});
+
+test("PDF source geometry is opt-in and obeys layout limits", () => {
+ const content = "BT /F1 10 Tf 10 100 Td (Hi) Tj ET";
+ const source = `%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj
+4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
+5 0 obj << /Length ${content.length} >> stream
+${content}
+endstream endobj
+trailer << /Root 1 0 R >>
+%%EOF`;
+ const data = new TextEncoder().encode(source);
+ const hint = { fileName: "a.pdf" };
+ const plain = new Markkit().extractDocument(data, hint);
+ assert.equal(plain.markdown, "Hi\n");
+ assert.ok(plain.layout == null);
+ const result = new Markkit({ pdfLayout: true }).extractDocument(data, hint);
+ assert.equal(result.markdown, plain.markdown);
+ assert.equal(result.layout.pages[0].width, 300);
+ assert.equal(result.layout.pages[0].blocks[0].kind, "paragraph");
+ assert.deepEqual(result.layout.pages[0].blocks[0].spanIds, [0]);
+ assert.equal(result.layout.pages[0].spans[0].words[0].text, "Hi");
+ assert.equal(result.layout.pages[0].height, 400);
+ assert.equal(result.layout.pages[0].spans[0].text, "Hi");
+ assert.deepEqual(result.layout.pages[0].spans[0].baseline, [[10, 300], [20, 300]]);
+ assert.throws(() => new Markkit({ pdfLayout: true, maxNodes: 1 }).extractDocument(data, hint), /max_nodes/);
+ assert.throws(() => new Markkit({ pdfLayout: true, maxOutputSize: 50 }).extractDocument(data, hint), /max_output_size/);
+});
diff --git a/crates/markkit-wasm/js/index.d.ts b/crates/markkit-wasm/js/index.d.ts
index 8e69612..915e9ce 100644
--- a/crates/markkit-wasm/js/index.d.ts
+++ b/crates/markkit-wasm/js/index.d.ts
@@ -20,6 +20,12 @@ export interface MarkkitOptions {
baseUrl?: string;
/** Keep only the detected main content (article body) of HTML pages. */
mainContent?: boolean;
+ /** Include PDF source geometry in extractDocument results. */
+ pdfLayout?: boolean;
+ pdfRemoveRepeatedHeaders?: boolean;
+ maxPdfPages?: number;
+ maxPdfStreamSize?: number;
+ maxPdfDecodedSize?: number;
preserveLinks?: boolean;
preserveImages?: boolean;
/** CSS selectors to include (empty = whole document). */
@@ -68,6 +74,7 @@ export interface Warning {
}
export interface Extraction {
+ layout?: PdfLayout;
markdown: string;
title: string | null;
format: string;
@@ -76,6 +83,42 @@ export interface Extraction {
metadata: Metadata;
}
+/** Source geometry in displayed crop-box points, x right and y down. */
+export interface PdfLayout {
+ version: number;
+ pages: {
+ page: number;
+ width: number;
+ height: number;
+ rotation: number;
+ blocks: PdfBlock[];
+ spans: {
+ id: number;
+ text: string;
+ /** Approximate em bounds: [left, top, right, bottom]. */
+ bbox: [number, number, number, number];
+ baseline: [[number, number], [number, number]];
+ font?: string;
+ size: number;
+ angle: number;
+ words: {text:string; bbox:[number,number,number,number]; baseline:[[number,number],[number,number]]}[];
+ bold?: boolean;
+ italic?: boolean;
+ role?: string;
+ ocr?: boolean;
+ }[];
+ }[];
+}
+
+export interface PdfBlock {
+ id:number;
+ kind:string;
+ bbox:[number,number,number,number];
+ spanIds:number[];
+ lines:{bbox:[number,number,number,number];spanIds:number[]}[];
+ cells?:{row:number;column:number;rowspan:number;colspan:number;bbox:[number,number,number,number];spanIds:number[]}[];
+}
+
export class Markkit {
constructor(options?: MarkkitOptions);
extractHtml(html: string): string;
diff --git a/crates/markkit-wasm/src/lib.rs b/crates/markkit-wasm/src/lib.rs
index fe279da..75f907c 100644
--- a/crates/markkit-wasm/src/lib.rs
+++ b/crates/markkit-wasm/src/lib.rs
@@ -27,6 +27,11 @@ struct Options {
/// Absolute URL relative links and images resolve against.
base_url: Option,
main_content: Option,
+ pdf_layout: Option,
+ pdf_remove_repeated_headers: Option,
+ max_pdf_pages: Option,
+ max_pdf_stream_size: Option,
+ max_pdf_decoded_size: Option,
preserve_links: Option,
preserve_images: Option,
/// Added to the default selector set.
@@ -57,6 +62,11 @@ impl Options {
max_nesting_depth,
base_url,
main_content,
+ pdf_layout,
+ pdf_remove_repeated_headers,
+ max_pdf_pages,
+ max_pdf_stream_size,
+ max_pdf_decoded_size,
preserve_links,
preserve_images,
max_archive_entries,
@@ -139,6 +149,23 @@ fn to_js(value: &T) -> Result {
.map_err(|e| JsError::new(&e.to_string()))
}
+fn camel_case_tree(value: &mut serde_json::Value) {
+ camel_case_keys(value);
+ match value {
+ serde_json::Value::Object(map) => {
+ for v in map.values_mut() {
+ camel_case_tree(v);
+ }
+ }
+ serde_json::Value::Array(list) => {
+ for v in list {
+ camel_case_tree(v);
+ }
+ }
+ _ => {}
+ }
+}
+
/// Rust serializes `snake_case`; JS callers get `camelCase` (`canonicalUrl`).
fn camel_case_keys(value: &mut serde_json::Value) {
if let serde_json::Value::Object(map) = value {
@@ -213,6 +240,9 @@ impl Markkit {
.map_err(js_err)?;
let mut value = serde_json::to_value(&extraction).map_err(js_err)?;
camel_case_keys(&mut value["metadata"]);
+ if let Some(layout) = value.get_mut("layout") {
+ camel_case_tree(layout);
+ }
to_js(&value)
}
diff --git a/crates/markkit/Cargo.toml b/crates/markkit/Cargo.toml
index 2cd2f2f..3a49378 100644
--- a/crates/markkit/Cargo.toml
+++ b/crates/markkit/Cargo.toml
@@ -17,6 +17,10 @@ readme = "../../README.md"
default = ["html"]
html = ["dep:scraper", "dep:html5ever", "dep:ego-tree", "dep:url"]
pdf = ["dep:hayro-syntax", "dep:miniz_oxide"]
+# Native OCR is opt-in; Rust rendering plus host-installed Tesseract.
+pdf-ocr = ["pdf", "dep:tempfile", "dep:hayro"]
+# Optional Rust OCR with caller-supplied, local ONNX models.
+pdf-ocr-ocrs = ["pdf-ocr", "dep:ocrs", "dep:rten"]
docx = ["dep:zip", "dep:quick-xml"]
pptx = ["dep:zip", "dep:quick-xml"]
spreadsheet = ["dep:calamine", "dep:zip", "dep:quick-xml", "dep:cfb"]
@@ -33,7 +37,13 @@ tool = ["dep:tower", "dep:futures-core", "dep:base64", "dep:tokio", "tokio/sync"
json-schema = ["dep:schemars"]
all_formats = ["html", "pdf", "docx", "pptx", "spreadsheet", "markdown", "email", "odt", "epub"]
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+hayro = { workspace = true, optional = true }
+ocrs = { workspace = true, optional = true }
+rten = { workspace = true, optional = true }
+
[dependencies]
+tempfile = { workspace = true, optional = true }
# Async (optional): only URL fetching and the tool's output channel need tokio.
tokio = { workspace = true, optional = true }
diff --git a/crates/markkit/src/config.rs b/crates/markkit/src/config.rs
index c1e34f5..7cb5650 100644
--- a/crates/markkit/src/config.rs
+++ b/crates/markkit/src/config.rs
@@ -3,6 +3,25 @@ use std::time::Duration;
use crate::extract::Markkit;
use crate::limits::Limits;
+/// Local OCR implementation for textless PDF pages. Tesseract is the default.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum PdfOcrBackend {
+ #[default]
+ Tesseract,
+ /// Rust inference; requires `pdf-ocr-ocrs` and explicit local ONNX models.
+ Ocrs,
+}
+impl std::str::FromStr for PdfOcrBackend {
+ type Err = String;
+ fn from_str(value: &str) -> Result {
+ match value {
+ "tesseract" => Ok(Self::Tesseract),
+ "ocrs" => Ok(Self::Ocrs),
+ _ => Err("OCR backend must be tesseract or ocrs".into()),
+ }
+ }
+}
+
/// Builder for configuring a Markkit instance.
#[derive(Debug, Clone)]
pub struct MarkkitBuilder {
@@ -39,6 +58,21 @@ pub struct MarkkitBuilder {
/// HTML element XPath, DOCX block index, spreadsheet sheet and range.
/// Default: false (no cost, no `source` in IR JSON).
pub track_sources: bool,
+ /// Retain PDF page dimensions and positioned text runs. Default: false.
+ /// Source geometry is separate from the semantic IR and Markdown output.
+ pub pdf_layout: bool,
+ /// Remove text repeated in page margins. Default: false.
+ pub pdf_remove_repeated_headers: bool,
+ /// OCR textless PDF pages with Hayro rendering and the selected OCR backend (`pdf-ocr` feature).
+ pub pdf_ocr: bool,
+ /// Installed Tesseract language(s), e.g. `eng` or `eng+deu`.
+ pub pdf_ocr_language: String,
+ /// OCR backend; requires `pdf_ocr(true)`. Default: Tesseract.
+ pub pdf_ocr_backend: PdfOcrBackend,
+ /// Local ONNX text detection model, required by the ocrs backend.
+ pub pdf_ocr_detection_model: Option,
+ /// Local ONNX text recognition model, required by the ocrs backend.
+ pub pdf_ocr_recognition_model: Option,
/// IR passes run after parsing, before rendering (see [`crate::transform`]).
pub transforms: crate::transform::TransformChain,
/// Keep only the detected main content (article body) of HTML pages,
@@ -74,6 +108,13 @@ impl Default for MarkkitBuilder {
allowed_hosts: Vec::new(),
base_url: None,
track_sources: false,
+ pdf_layout: false,
+ pdf_remove_repeated_headers: false,
+ pdf_ocr: false,
+ pdf_ocr_language: "eng".into(),
+ pdf_ocr_backend: PdfOcrBackend::default(),
+ pdf_ocr_detection_model: None,
+ pdf_ocr_recognition_model: None,
transforms: Default::default(),
main_content: false,
html_rules: Vec::new(),
@@ -83,6 +124,41 @@ impl Default for MarkkitBuilder {
}
impl MarkkitBuilder {
+ /// Retain PDF source geometry in `ParsedDocument::layout` and
+ /// `Extraction::layout`. Has no effect on other formats or Markdown.
+ pub fn pdf_layout(mut self, enabled: bool) -> Self {
+ self.pdf_layout = enabled;
+ self
+ }
+
+ pub fn pdf_remove_repeated_headers(mut self, enabled: bool) -> Self {
+ self.pdf_remove_repeated_headers = enabled;
+ self
+ }
+
+ pub fn pdf_ocr(mut self, enabled: bool) -> Self {
+ self.pdf_ocr = enabled;
+ self
+ }
+
+ pub fn pdf_ocr_language(mut self, language: impl Into) -> Self {
+ self.pdf_ocr_language = language.into();
+ self
+ }
+
+ pub fn pdf_ocr_backend(mut self, backend: PdfOcrBackend) -> Self {
+ self.pdf_ocr_backend = backend;
+ self
+ }
+ pub fn pdf_ocr_detection_model(mut self, path: impl Into) -> Self {
+ self.pdf_ocr_detection_model = Some(path.into());
+ self
+ }
+ pub fn pdf_ocr_recognition_model(mut self, path: impl Into) -> Self {
+ self.pdf_ocr_recognition_model = Some(path.into());
+ self
+ }
+
pub fn max_input_size(mut self, size: usize) -> Self {
self.limits.max_input_size = size;
self
diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs
index 61dea98..e225e03 100644
--- a/crates/markkit/src/extract.rs
+++ b/crates/markkit/src/extract.rs
@@ -10,6 +10,9 @@ use crate::limits::Budget;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct Extraction {
+ /// Optional PDF source geometry (enable with `pdf_layout(true)`).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub layout: Option,
/// Rendered Markdown. Non-empty output always ends with exactly one `\n`
/// (POSIX text convention, safe to concatenate); empty documents render `""`.
pub markdown: String,
@@ -176,6 +179,9 @@ pub struct ExtractionStats {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct ParsedDocument {
+ /// Optional PDF source geometry, independent of semantic IR transforms.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub layout: Option,
/// Canonical IR (blocks carry [`crate::ir::Source`] when
/// [`MarkkitBuilder::track_sources`] is enabled).
pub document: Document,
@@ -196,6 +202,7 @@ impl ParsedDocument {
/// [`crate::registry::FormatParser`]s).
pub fn new(document: Document, format: Format) -> Self {
Self {
+ layout: None,
document,
title: None,
format,
@@ -223,6 +230,7 @@ impl ParsedDocument {
fn into_extraction(self, markdown: String) -> Extraction {
Extraction {
+ layout: self.layout,
markdown,
title: self.title,
format: self.format,
@@ -694,8 +702,17 @@ impl Markkit {
crate::pdf::parse(bytes, &self.config, budget)
})?;
let mut p = ParsedDocument::new(out.doc, Format::Pdf);
+ p.layout = out.layout;
+ self.config.transforms.apply(&mut p.document)?;
p.title = out.title;
p.warnings = out.warnings;
+ p.stats.tables = Some(
+ p.document
+ .nodes
+ .iter()
+ .filter(|n| matches!(n, crate::ir::Node::Table { .. }))
+ .count(),
+ );
p.stats.pages = Some(out.pages);
Ok(p)
}
diff --git a/crates/markkit/src/ir.rs b/crates/markkit/src/ir.rs
index e3ec9b3..a3f21ad 100644
--- a/crates/markkit/src/ir.rs
+++ b/crates/markkit/src/ir.rs
@@ -376,6 +376,9 @@ pub struct Source {
/// Paged formats: 1-based page number (PPTX: slide number).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option,
+ /// PDF: page-local semantic layout block ID.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub layout_block: Option,
}
impl Source {
diff --git a/crates/markkit/src/layout.rs b/crates/markkit/src/layout.rs
new file mode 100644
index 0000000..c2cc4df
--- /dev/null
+++ b/crates/markkit/src/layout.rs
@@ -0,0 +1,116 @@
+//! Optional PDF source geometry, separate from the semantic document IR.
+//!
+//! Decision: source runs stay in drawing order; words retain glyph advances.
+//! Semantic blocks/lines/table cells are in reading order and link to IR via
+//! `Source::page` + `Source::layout_block`. IDs are stable for identical input
+//! and configuration. Coordinates are points (1/72 inch), relative to the
+//! displayed crop box top-left, x right and y down after rotation/UserUnit.
+//! Font bounds approximate 0.8 em ascent and 0.2 em descent, not ink outlines;
+//! OCR bounds come from the recognizer. Off-page text is retained.
+//! Geometry stays source data if an embedder transforms the semantic IR.
+
+use serde::{Deserialize, Serialize};
+
+/// Positioned PDF text. Present only when `pdf_layout(true)` is requested.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct PdfLayout {
+ /// Layout schema version, independent of the semantic IR version.
+ pub version: u32,
+ /// Extracted pages, including empty pages, up to `max_pdf_pages`.
+ pub pages: Vec,
+}
+
+/// Display dimensions and positioned text for a PDF page.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct PdfPageLayout {
+ /// One-based page number.
+ pub page: u32,
+ /// Display width in points, after cropping, rotation and UserUnit.
+ pub width: f64,
+ /// Display height in points.
+ pub height: f64,
+ /// Applied clockwise page rotation: 0, 90, 180 or 270 degrees.
+ pub rotation: u16,
+ /// Text-showing runs in drawing order. `TJ` strings are separate runs.
+ pub spans: Vec,
+ /// Semantic regions in reading order. Source spans retain drawing order.
+ #[serde(default)]
+ pub blocks: Vec,
+}
+
+/// One text-showing run, before line joining or dehyphenation.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct PdfTextSpan {
+ /// Zero-based run index within its page (not a semantic IR node ID).
+ pub id: usize,
+ /// Decoded text; ligatures expanded and control characters removed.
+ pub text: String,
+ /// Approximate axis-aligned bounds: `[left, top, right, bottom]`.
+ pub bbox: [f64; 4],
+ /// Start and end of the text baseline, including advances and spacing.
+ pub baseline: [[f64; 2]; 2],
+ /// PDF BaseFont name, including any subset prefix, when available.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub font: Option,
+ /// Length of the transformed vertical em vector in points.
+ pub size: f64,
+ /// Clockwise baseline direction in display coordinates, in [0, 360).
+ pub angle: f64,
+ #[serde(default)]
+ pub words: Vec,
+ /// True for a run recognized from a rasterized page.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub ocr: bool,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub bold: bool,
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub italic: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub role: Option,
+}
+
+/// A word positioned using glyph advances, before paragraph normalization.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct PdfWord {
+ pub text: String,
+ pub bbox: [f64; 4],
+ pub baseline: [[f64; 2]; 2],
+}
+
+/// Semantic region in reading order. `(page, id)` links to `Source::layout_block`.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct PdfBlock {
+ pub id: usize,
+ /// `heading`, `paragraph`, `list`, `table`, `header` or `footer`.
+ pub kind: String,
+ pub bbox: [f64; 4],
+ pub span_ids: Vec,
+ pub lines: Vec,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub cells: Vec,
+}
+
+/// Line geometry and references to source text runs.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct PdfLine {
+ pub bbox: [f64; 4],
+ pub span_ids: Vec,
+}
+
+/// A table cell, with zero-based row/column and authored/inferred spans.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct PdfCell {
+ pub row: usize,
+ pub column: usize,
+ pub rowspan: u32,
+ pub colspan: u32,
+ pub bbox: [f64; 4],
+ pub span_ids: Vec,
+}
diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs
index 0733327..b69b174 100644
--- a/crates/markkit/src/lib.rs
+++ b/crates/markkit/src/lib.rs
@@ -42,7 +42,8 @@
//! - **Jupyter notebooks** (always on): `.ipynb`, cells and text outputs
//! - **CSV / TSV** (always on): `text/csv`, `text/tab-separated-values`, `.csv`, `.tsv`, one table
//! - **Markdown** (`markdown`): `text/markdown`, `.md`, normalized through the IR
-//! - **PDF** (`pdf`): Text layer of PDF documents (no OCR); encrypted PDFs are
+//! - **PDF** (`pdf`): Text, layout and tables; optional native OCR (`pdf-ocr`)
+//! uses Rust Hayro rendering and installed Tesseract. Encrypted PDFs are
//! rejected with [`Error::UnsupportedFormat`]
//! - **URL fetching** (`http_client`, opt-in): Fetch and extract from URLs via reqwest
//! - **Agent tool** (`tool`): `markkit::tool::MarkkitTool`, an LLM tool following the
@@ -77,6 +78,7 @@ pub mod format;
#[cfg(feature = "html")]
pub mod html;
pub mod ir;
+pub mod layout;
pub mod limits;
#[cfg(feature = "markdown")]
mod markdown;
@@ -108,7 +110,7 @@ pub mod transform;
#[cfg_attr(not(feature = "docx"), allow(dead_code))] // DOCX uses every helper
mod xml;
-pub use config::MarkkitBuilder;
+pub use config::{MarkkitBuilder, PdfOcrBackend};
pub use error::Error;
pub use extract::{
Extraction, ExtractionStats, Markkit, Metadata, ParsedDocument, Warning, WarningCode,
diff --git a/crates/markkit/src/limits.rs b/crates/markkit/src/limits.rs
index 78e204a..f6425cd 100644
--- a/crates/markkit/src/limits.rs
+++ b/crates/markkit/src/limits.rs
@@ -126,7 +126,7 @@ pub struct Limits {
/// [`HARD_MAX_NESTING_DEPTH`]. Default: 256.
pub max_nesting_depth: usize,
/// Maximum parsed nodes (HTML DOM nodes; XML elements and text nodes summed
- /// over DOCX parts). Default: 5 000 000.
+ /// over DOCX parts; retained PDF layout pages/runs). Default: 5 000 000.
pub max_nodes: usize,
/// Maximum number of entries in a ZIP container (DOCX/XLSX/ODS). Default: 10 000.
pub max_archive_entries: usize,
diff --git a/crates/markkit/src/pdf/analyze.rs b/crates/markkit/src/pdf/analyze.rs
new file mode 100644
index 0000000..b5b3121
--- /dev/null
+++ b/crates/markkit/src/pdf/analyze.rs
@@ -0,0 +1,1128 @@
+//! Deterministic geometry → regions → semantic IR.
+//!
+//! Decisions: ruling lines take precedence over alignment tables. Borderless
+//! tables require repeated row/column alignment plus numeric data or explicit
+//! header styling; a page of prose columns is not evidence of a table. XY cuts
+//! order columns within bands separated by spanning headings/tables. All pair
+//! comparisons and grid expansion are bounded before allocating a grid.
+
+use crate::ir::{ListItem, Node, Source, TableCell};
+use crate::layout::{PdfBlock, PdfCell, PdfLayout, PdfLine};
+use crate::limits::{Budget, LimitExceeded, LimitKind};
+use crate::{Error, MarkkitBuilder};
+use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
+
+#[derive(Clone, Copy, Debug)]
+pub(super) struct Rule {
+ pub a: [f64; 2],
+ pub b: [f64; 2],
+}
+pub(super) fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
+ [
+ a[0].min(b[0]),
+ a[1].min(b[1]),
+ a[2].max(b[2]),
+ a[3].max(b[3]),
+ ]
+}
+fn bbox(points: impl Iterator- ) -> [f64; 4] {
+ points.reduce(union).unwrap_or([0.0; 4])
+}
+fn cx(b: [f64; 4]) -> f64 {
+ (b[0] + b[2]) * 0.5
+}
+fn cy(b: [f64; 4]) -> f64 {
+ (b[1] + b[3]) * 0.5
+}
+fn inside(b: [f64; 4], x: f64, y: f64) -> bool {
+ x >= b[0] - 0.5 && x <= b[2] + 0.5 && y >= b[1] - 0.5 && y <= b[3] + 0.5
+}
+
+struct Work<'a> {
+ remaining: usize,
+ clock: &'a Budget,
+}
+impl Work<'_> {
+ fn tick(&mut self, n: usize) -> Result<(), Error> {
+ self.clock.check_time()?;
+ self.remaining = self.remaining.checked_sub(n).ok_or_else(|| {
+ LimitExceeded::new(
+ LimitKind::MaxPdfDecodedSize,
+ "PDF geometry analysis exceeds max_pdf_decoded_size work budget",
+ )
+ })?;
+ Ok(())
+ }
+}
+#[derive(Clone, Copy)]
+struct Coords(u16);
+impl Coords {
+ fn point(self, p: [f64; 2]) -> [f64; 2] {
+ match self.0 {
+ 90 => [p[1], -p[0]],
+ 180 => [-p[0], -p[1]],
+ 270 => [-p[1], p[0]],
+ _ => p,
+ }
+ }
+ fn inverse(self) -> Self {
+ Self((360 - self.0) % 360)
+ }
+ fn rect(self, b: [f64; 4]) -> [f64; 4] {
+ bbox(
+ [[b[0], b[1]], [b[0], b[3]], [b[2], b[1]], [b[2], b[3]]]
+ .into_iter()
+ .map(|p| {
+ let [x, y] = self.point(p);
+ [x, y, x, y]
+ }),
+ )
+ }
+}
+#[derive(Clone, Debug)]
+struct Word {
+ text: String,
+ rect: [f64; 4],
+ display: [f64; 4],
+ y: f64,
+ size: f64,
+ span: usize,
+ bold: bool,
+ italic: bool,
+ role: Option,
+}
+#[derive(Clone, Debug)]
+struct Line {
+ words: Vec,
+ rect: [f64; 4],
+ size: f64,
+ y: f64,
+}
+impl Line {
+ fn new(words: Vec) -> Self {
+ let rect = bbox(words.iter().map(|w| w.rect));
+ let size = words.iter().map(|w| w.size).fold(0.1, f64::max);
+ let y = words.first().map_or(0.0, |w| w.y);
+ Self {
+ words,
+ rect,
+ size,
+ y,
+ }
+ }
+ fn text(&self) -> String {
+ plain(&self.words)
+ }
+ fn geometry(&self) -> PdfLine {
+ PdfLine {
+ bbox: bbox(self.words.iter().map(|w| w.display)),
+ span_ids: ids(&self.words),
+ }
+ }
+}
+fn ids(words: &[Word]) -> Vec {
+ words
+ .iter()
+ .map(|w| w.span)
+ .collect::>()
+ .into_iter()
+ .collect()
+}
+fn plain(words: &[Word]) -> String {
+ let mut s = String::new();
+ let mut prev: Option<&Word> = None;
+ for w in words {
+ if let Some(p) = prev
+ && ((w.y - p.y).abs() > w.size * 0.4 || w.rect[0] - p.rect[2] > w.size * 0.15)
+ {
+ s.push(' ');
+ }
+ s.push_str(&w.text);
+ prev = Some(w);
+ }
+ s
+}
+fn inline(words: &[Word]) -> Vec {
+ let mut nodes = Vec::new();
+ let mut buffer = String::new();
+ let mut style = (false, false);
+ let mut prev: Option<&Word> = None;
+ let flush = |nodes: &mut Vec, buffer: &mut String, style: (bool, bool)| {
+ if buffer.is_empty() {
+ return;
+ }
+ let mut node = Node::Text(std::mem::take(buffer));
+ if style.1 {
+ node = Node::Italic {
+ children: vec![node],
+ };
+ }
+ if style.0 {
+ node = Node::Bold {
+ children: vec![node],
+ };
+ }
+ nodes.push(node);
+ };
+ for w in words {
+ let gap = prev.is_some_and(|p| {
+ (w.y - p.y).abs() > w.size * 0.4 || w.rect[0] - p.rect[2] > w.size * 0.15
+ });
+ if (w.bold, w.italic) != style {
+ flush(&mut nodes, &mut buffer, style);
+ if gap {
+ nodes.push(Node::Text(" ".into()));
+ }
+ style = (w.bold, w.italic);
+ } else if gap {
+ buffer.push(' ');
+ }
+ buffer.push_str(&w.text);
+ prev = Some(w);
+ }
+ flush(&mut nodes, &mut buffer, style);
+ nodes
+}
+
+fn rows(mut words: Vec) -> Vec {
+ words.sort_by(|a, b| {
+ a.y.total_cmp(&b.y)
+ .then(a.rect[0].total_cmp(&b.rect[0]))
+ .then(a.span.cmp(&b.span))
+ });
+ let mut rows: Vec = Vec::new();
+ for w in words {
+ if let Some(last) = rows.last_mut()
+ && (last.y - w.y).abs() <= 0.4 * last.size.max(w.size)
+ {
+ last.rect = union(last.rect, w.rect);
+ last.size = last.size.max(w.size);
+ last.words.push(w);
+ } else {
+ rows.push(Line::new(vec![w]));
+ }
+ }
+ for row in &mut rows {
+ row.words
+ .sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]).then(a.span.cmp(&b.span)));
+ }
+ rows
+}
+fn fragments(row: Line) -> Vec {
+ let mut out = Vec::new();
+ let mut words = Vec::new();
+ let mut right = None;
+ for w in row.words {
+ if right.is_some_and(|r| w.rect[0] - r >= 0.8 * row.size) && !words.is_empty() {
+ out.push(Line::new(std::mem::take(&mut words)));
+ }
+ right = Some(w.rect[2]);
+ words.push(w);
+ }
+ if !words.is_empty() {
+ out.push(Line::new(words));
+ }
+ out
+}
+#[derive(Clone)]
+struct Region {
+ rect: [f64; 4],
+ lines: Vec,
+ node: Option,
+ cells: Vec,
+ removed: Option<&'static str>,
+}
+impl Region {
+ fn line(line: Line) -> Self {
+ Self {
+ rect: line.rect,
+ lines: vec![line],
+ node: None,
+ cells: Vec::new(),
+ removed: None,
+ }
+ }
+}
+
+pub(super) fn analyze(
+ layout: &mut PdfLayout,
+ rules: &[Vec],
+ cfg: &MarkkitBuilder,
+ clock: &Budget,
+) -> Result, Error> {
+ let mut work = Work {
+ remaining: cfg.limits.max_pdf_decoded_size,
+ clock,
+ };
+ let repeated = repeated_margins(layout);
+ let mut sizes: HashMap = HashMap::new();
+ for p in &layout.pages {
+ for s in &p.spans {
+ *sizes.entry((s.size * 2.0).round() as i32).or_default() += s.text.chars().count();
+ }
+ }
+ let body = sizes
+ .into_iter()
+ .max_by_key(|(s, n)| (*n, -*s))
+ .map_or(10.0, |(s, _)| f64::from(s) * 0.5);
+ let mut heading_sizes = layout
+ .pages
+ .iter()
+ .flat_map(|p| &p.spans)
+ .filter(|s| s.size >= body * 1.2 && s.text.chars().count() <= 200)
+ .map(|s| (s.size * 2.0).round() as i32)
+ .collect::>();
+ heading_sizes.sort_unstable_by(|a, b| b.cmp(a));
+ heading_sizes.dedup();
+ let mut nodes = Vec::new();
+ for (pi, page) in layout.pages.iter_mut().enumerate() {
+ work.tick(1)?;
+ let mut angles = HashMap::::new();
+ for s in &page.spans {
+ let a = ((s.angle / 90.0).round() as u16 % 4) * 90;
+ *angles.entry(a).or_default() += s.text.chars().count();
+ }
+ let coords = Coords(
+ angles
+ .into_iter()
+ .max_by_key(|(a, n)| (*n, std::cmp::Reverse(*a)))
+ .map_or(0, |(a, _)| a),
+ );
+ let words = page
+ .spans
+ .iter()
+ .flat_map(|s| {
+ s.words.iter().map(move |w| Word {
+ text: w.text.clone(),
+ rect: coords.rect(w.bbox),
+ display: w.bbox,
+ y: coords.point(w.baseline[0])[1],
+ size: s.size.max(0.1),
+ span: s.id,
+ bold: s.bold,
+ italic: s.italic,
+ role: s.role.clone(),
+ })
+ })
+ .collect::>();
+ let mut removed = Vec::new();
+ let mut active = Vec::new();
+ for row in rows(words) {
+ let text = margin_key(&row.text());
+ let margin = row.words.iter().all(|w| w.display[3] < page.height * 0.08)
+ || row.words.iter().all(|w| w.display[1] > page.height * 0.92);
+ if cfg.pdf_remove_repeated_headers && margin && repeated.contains(&text) {
+ let kind = if cy(row.words[0].display) < page.height * 0.5 {
+ "header"
+ } else {
+ "footer"
+ };
+ let mut r = Region::line(row);
+ r.removed = Some(kind);
+ removed.push(r);
+ } else {
+ active.extend(row.words);
+ }
+ }
+ let mut used = vec![false; active.len()];
+ let rs = rules
+ .get(pi)
+ .map_or(&[][..], Vec::as_slice)
+ .iter()
+ .map(|r| Rule {
+ a: coords.point(r.a),
+ b: coords.point(r.b),
+ })
+ .collect::>();
+ let mut regions = ruled_tables(&active, &mut used, &rs, coords, cfg, &mut work)?;
+ let remaining = active
+ .into_iter()
+ .enumerate()
+ .filter_map(|(i, w)| (!used[i]).then_some(w))
+ .collect::>();
+ let mut line_rows = rows(remaining)
+ .into_iter()
+ .map(fragments)
+ .collect::>();
+ regions.extend(borderless_tables(&mut line_rows, coords, cfg, &mut work)?);
+ regions.extend(line_rows.into_iter().flatten().map(Region::line));
+ regions.extend(removed);
+ let mut ordered = Vec::new();
+ order(regions, body, 0, &mut ordered, &mut work)?;
+ let mut paras: Vec = Vec::new();
+ let mut force = true;
+ for atom in ordered {
+ let Some(mut r) = atom else {
+ force = true;
+ continue;
+ };
+ if r.node.is_some() || r.removed.is_some() {
+ paras.push(r);
+ force = true;
+ continue;
+ }
+ let line = &r.lines[0];
+ let text = line.text();
+ let item = list_prefix(&text).is_some();
+ let heading = is_heading(line, body);
+ let join = !force
+ && !item
+ && !heading
+ && paras.last().is_some_and(|p| {
+ p.node.is_none()
+ && p.removed.is_none()
+ && p.lines.last().is_some_and(|prev| {
+ let d = line.y - prev.y;
+ d > 0.0
+ && d <= 1.7 * line.size.max(prev.size)
+ && (prev.size - line.size).abs() < 0.15 * prev.size.max(line.size)
+ && !is_heading(prev, body)
+ })
+ });
+ if join {
+ let last = paras.last_mut().unwrap();
+ last.rect = union(last.rect, r.rect);
+ last.lines.append(&mut r.lines);
+ } else {
+ paras.push(r);
+ }
+ force = false;
+ }
+ page.blocks.clear();
+ for r in paras {
+ work.tick(1)?;
+ clock.add_nodes(1)?;
+ let all = r
+ .lines
+ .iter()
+ .flat_map(|l| l.words.iter().cloned())
+ .collect::>();
+ let mut block = PdfBlock {
+ id: page.blocks.len(),
+ kind: r.removed.unwrap_or("paragraph").into(),
+ bbox: bbox(all.iter().map(|w| w.display)),
+ span_ids: ids(&all),
+ lines: r.lines.iter().map(Line::geometry).collect(),
+ cells: r.cells,
+ };
+ if !block.cells.is_empty() {
+ block.bbox = bbox(block.cells.iter().map(|c| c.bbox));
+ }
+ if r.removed.is_some() {
+ page.blocks.push(block);
+ continue;
+ }
+ let mut node = if let Some(n) = r.node {
+ block.kind = "table".into();
+ n
+ } else {
+ let mut children = Vec::new();
+ let mut text = String::new();
+ for l in &r.lines {
+ let value = l.text();
+ if !children.is_empty() {
+ if text.ends_with('-')
+ && value.chars().next().is_some_and(char::is_lowercase)
+ {
+ trim_hyphen(&mut children);
+ } else {
+ children.push(Node::Text(" ".into()));
+ }
+ }
+ children.extend(inline(&l.words));
+ text.push_str(&value);
+ }
+ if let Some((ordered, start, len)) = list_prefix(&text) {
+ block.kind = "list".into();
+ remove_prefix(&mut children, len);
+ Node::List {
+ ordered,
+ start,
+ items: vec![ListItem::new(vec![Node::paragraph(children)])],
+ source: None,
+ }
+ } else if is_heading(&r.lines[0], body) {
+ block.kind = "heading".into();
+ let level = r.lines[0]
+ .words
+ .iter()
+ .find_map(|w| {
+ w.role
+ .as_deref()
+ .and_then(|r| r.strip_prefix('H'))
+ .and_then(|n| n.parse::().ok())
+ })
+ .unwrap_or_else(|| {
+ (heading_sizes
+ .iter()
+ .position(|s| *s == (r.lines[0].size * 2.0).round() as i32)
+ .unwrap_or(0)
+ + 1)
+ .min(6) as u8
+ });
+ Node::heading(level, children)
+ } else {
+ Node::paragraph(children)
+ }
+ };
+ if cfg.track_sources || cfg.pdf_layout {
+ let mut source = Source::default().with_page(page.page);
+ source.layout_block = cfg.pdf_layout.then_some(block.id);
+ node.set_source(source);
+ }
+ // Adjacent list items share one semantic/layout block, never across pages.
+ let merge = match (nodes.last(), &node) {
+ (
+ Some(Node::List {
+ ordered: a,
+ start,
+ items,
+ ..
+ }),
+ Node::List {
+ ordered: b,
+ start: next,
+ ..
+ },
+ ) => {
+ a == b
+ && (!a || next == &Some(start.unwrap_or(1) + items.len() as u64))
+ && page.blocks.last().is_some_and(|b| b.kind == "list")
+ }
+ _ => false,
+ };
+ if merge {
+ if let (Some(Node::List { items, .. }), Node::List { items: more, .. }) =
+ (nodes.last_mut(), node)
+ {
+ items.extend(more);
+ }
+ let prev = page.blocks.last_mut().unwrap();
+ prev.bbox = union(prev.bbox, block.bbox);
+ prev.lines.extend(block.lines);
+ prev.span_ids.extend(block.span_ids);
+ prev.span_ids.sort_unstable();
+ prev.span_ids.dedup();
+ } else {
+ nodes.push(node);
+ page.blocks.push(block);
+ }
+ }
+ }
+ // Account for retained block/cell metadata before it is returned. Word/source
+ // text is separately bounded during interpretation; this checks added geometry.
+ let extra = layout
+ .pages
+ .iter()
+ .flat_map(|p| &p.blocks)
+ .try_fold(0usize, |n, b| {
+ n.checked_add(
+ std::mem::size_of::()
+ + b.span_ids.len() * std::mem::size_of::()
+ + b.lines
+ .iter()
+ .map(|l| {
+ std::mem::size_of::()
+ + l.span_ids.len() * std::mem::size_of::()
+ })
+ .sum::()
+ + b.cells
+ .iter()
+ .map(|c| {
+ std::mem::size_of::()
+ + c.span_ids.len() * std::mem::size_of::()
+ })
+ .sum::(),
+ )
+ });
+ if extra.is_none_or(|n| n > cfg.limits.max_pdf_decoded_size) {
+ return Err(LimitExceeded::new(
+ LimitKind::MaxPdfDecodedSize,
+ "PDF region geometry exceeds max_pdf_decoded_size",
+ )
+ .into());
+ }
+ Ok(nodes)
+}
+fn is_heading(l: &Line, body: f64) -> bool {
+ l.words.iter().any(|w| {
+ w.role
+ .as_deref()
+ .is_some_and(|r| matches!(r, "H1" | "H2" | "H3" | "H4" | "H5" | "H6"))
+ }) || (l.size >= body * 1.2 && l.text().chars().count() <= 200)
+}
+fn trim_hyphen(nodes: &mut [Node]) {
+ if let Some(n) = nodes.last_mut() {
+ match n {
+ Node::Text(s) => {
+ if s.ends_with('-') {
+ s.pop();
+ }
+ }
+ Node::Bold { children } | Node::Italic { children } => trim_hyphen(children),
+ _ => {}
+ }
+ }
+}
+fn remove_prefix(nodes: &mut Vec, mut bytes: usize) {
+ while bytes > 0 && !nodes.is_empty() {
+ if let Node::Text(s) = &mut nodes[0] {
+ if s.len() <= bytes {
+ bytes -= s.len();
+ nodes.remove(0);
+ } else {
+ s.drain(..bytes);
+ bytes = 0;
+ }
+ } else {
+ break;
+ }
+ }
+}
+fn list_prefix(s: &str) -> Option<(bool, Option, usize)> {
+ if let Some(c) = s.chars().next()
+ && super::BULLETS.contains(&c)
+ && s[c.len_utf8()..].starts_with(' ')
+ {
+ return Some((false, None, c.len_utf8() + 1));
+ }
+ let n = s.bytes().take_while(u8::is_ascii_digit).count();
+ if (1..=10).contains(&n) && s.get(n..n + 2).is_some_and(|x| x == ". " || x == ") ") {
+ return Some((true, s[..n].parse().ok(), n + 2));
+ }
+ None
+}
+fn margin_key(s: &str) -> String {
+ s.chars()
+ .map(|c| {
+ if c.is_ascii_digit() {
+ '#'
+ } else {
+ c.to_ascii_lowercase()
+ }
+ })
+ .collect()
+}
+fn repeated_margins(layout: &PdfLayout) -> HashSet {
+ let mut counts = HashMap::::new();
+ for p in &layout.pages {
+ let mut seen = HashSet::new();
+ for s in &p.spans {
+ if s.bbox[3] < p.height * 0.08 || s.bbox[1] > p.height * 0.92 {
+ seen.insert(margin_key(&s.text));
+ }
+ }
+ for key in seen {
+ *counts.entry(key).or_default() += 1;
+ }
+ }
+ counts
+ .into_iter()
+ .filter_map(|(k, n)| (n >= 2 && n * 5 >= layout.pages.len() * 3).then_some(k))
+ .collect()
+}
+
+// A bounded union-find serves both connected ruling components and merged cells.
+struct Sets(Vec);
+impl Sets {
+ fn new(n: usize) -> Self {
+ Self((0..n).collect())
+ }
+ fn root(&mut self, mut i: usize) -> usize {
+ while self.0[i] != i {
+ self.0[i] = self.0[self.0[i]];
+ i = self.0[i];
+ }
+ i
+ }
+ fn join(&mut self, a: usize, b: usize) {
+ let a = self.root(a);
+ let b = self.root(b);
+ self.0[b] = a;
+ }
+}
+#[derive(Clone, Copy)]
+struct Seg {
+ vertical: bool,
+ pos: f64,
+ lo: f64,
+ hi: f64,
+}
+fn segment(r: Rule) -> Option {
+ if (r.a[0] - r.b[0]).abs() < 0.75 && (r.a[1] - r.b[1]).abs() > 3.0 {
+ Some(Seg {
+ vertical: true,
+ pos: (r.a[0] + r.b[0]) * 0.5,
+ lo: r.a[1].min(r.b[1]),
+ hi: r.a[1].max(r.b[1]),
+ })
+ } else if (r.a[1] - r.b[1]).abs() < 0.75 && (r.a[0] - r.b[0]).abs() > 3.0 {
+ Some(Seg {
+ vertical: false,
+ pos: (r.a[1] + r.b[1]) * 0.5,
+ lo: r.a[0].min(r.b[0]),
+ hi: r.a[0].max(r.b[0]),
+ })
+ } else {
+ None
+ }
+}
+fn unique(mut vals: Vec) -> Vec {
+ vals.sort_by(f64::total_cmp);
+ vals.dedup_by(|a, b| (*a - *b).abs() < 0.75);
+ vals
+}
+fn covers(
+ segs: &[Seg],
+ vertical: bool,
+ pos: f64,
+ lo: f64,
+ hi: f64,
+ work: &mut Work<'_>,
+) -> Result {
+ let mut parts = Vec::new();
+ for s in segs {
+ work.tick(1)?;
+ if s.vertical == vertical && (s.pos - pos).abs() < 0.75 && s.hi >= lo && s.lo <= hi {
+ parts.push((s.lo, s.hi));
+ }
+ }
+ parts.sort_by(|a, b| a.0.total_cmp(&b.0));
+ let mut end = lo;
+ for (a, b) in parts {
+ if a > end + 0.75 {
+ return Ok(false);
+ }
+ end = end.max(b);
+ }
+ Ok(end >= hi - 0.75)
+}
+fn grid_limit(rows: usize, cols: usize, cfg: &MarkkitBuilder) -> Result<(), Error> {
+ if rows > cfg.limits.max_sheet_rows
+ || cols > cfg.limits.max_sheet_cols
+ || rows
+ .checked_mul(cols)
+ .is_none_or(|n| n as u64 > cfg.limits.max_spreadsheet_cells)
+ {
+ Err(LimitExceeded::new(
+ LimitKind::MaxSpreadsheetCells,
+ "PDF table exceeds row/column/grid limits",
+ )
+ .into())
+ } else {
+ Ok(())
+ }
+}
+fn ruled_tables(
+ words: &[Word],
+ used: &mut [bool],
+ rules: &[Rule],
+ coords: Coords,
+ cfg: &MarkkitBuilder,
+ work: &mut Work<'_>,
+) -> Result, Error> {
+ let segs = rules.iter().filter_map(|r| segment(*r)).collect::>();
+ let mut sets = Sets::new(segs.len());
+ for (i, a) in segs.iter().enumerate() {
+ for (j, b) in segs[..i].iter().enumerate() {
+ work.tick(1)?;
+ let touch = if a.vertical == b.vertical {
+ (a.pos - b.pos).abs() < 0.75 && a.lo <= b.hi + 0.75 && b.lo <= a.hi + 0.75
+ } else {
+ let (v, h) = if a.vertical { (a, b) } else { (b, a) };
+ v.pos >= h.lo - 0.75
+ && v.pos <= h.hi + 0.75
+ && h.pos >= v.lo - 0.75
+ && h.pos <= v.hi + 0.75
+ };
+ if touch {
+ sets.join(i, j);
+ }
+ }
+ }
+ let mut groups = BTreeMap::>::new();
+ for (i, s) in segs.into_iter().enumerate() {
+ groups.entry(sets.root(i)).or_default().push(s);
+ }
+ let mut out = Vec::new();
+ for segs in groups.into_values() {
+ let xs = unique(segs.iter().filter(|s| s.vertical).map(|s| s.pos).collect());
+ let ys = unique(segs.iter().filter(|s| !s.vertical).map(|s| s.pos).collect());
+ if xs.len() < 3 || ys.len() < 3 {
+ continue;
+ }
+ let (nr, nc) = (ys.len() - 1, xs.len() - 1);
+ grid_limit(nr, nc, cfg)?;
+ work.clock.add_nodes(nr * nc)?;
+ let bounds = [xs[0], ys[0], *xs.last().unwrap(), *ys.last().unwrap()];
+ if words
+ .iter()
+ .enumerate()
+ .filter(|(i, w)| !used[*i] && inside(bounds, cx(w.rect), cy(w.rect)))
+ .count()
+ < 2
+ {
+ continue;
+ }
+ let mut cells = Sets::new(nr * nc);
+ for r in 0..nr {
+ for c in 0..nc {
+ if c + 1 < nc && !covers(&segs, true, xs[c + 1], ys[r], ys[r + 1], work)? {
+ cells.join(r * nc + c, r * nc + c + 1);
+ }
+ if r + 1 < nr && !covers(&segs, false, ys[r + 1], xs[c], xs[c + 1], work)? {
+ cells.join(r * nc + c, (r + 1) * nc + c);
+ }
+ }
+ }
+ let mut groups = BTreeMap::>::new();
+ for r in 0..nr {
+ for c in 0..nc {
+ groups
+ .entry(cells.root(r * nc + c))
+ .or_default()
+ .push((r, c));
+ }
+ }
+ if groups.values().any(|slots| {
+ let r0 = slots.iter().map(|x| x.0).min().unwrap();
+ let r1 = slots.iter().map(|x| x.0).max().unwrap();
+ let c0 = slots.iter().map(|x| x.1).min().unwrap();
+ let c1 = slots.iter().map(|x| x.1).max().unwrap();
+ (r1 - r0 + 1) * (c1 - c0 + 1) != slots.len()
+ }) {
+ continue;
+ }
+ let mut assigned = BTreeMap::>::new();
+ let mut selected = Vec::new();
+ for (i, w) in words.iter().enumerate() {
+ work.tick(1)?;
+ if !used[i] && inside(bounds, cx(w.rect), cy(w.rect)) {
+ let c = xs
+ .partition_point(|x| *x <= cx(w.rect))
+ .saturating_sub(1)
+ .min(nc - 1);
+ let r = ys
+ .partition_point(|y| *y <= cy(w.rect))
+ .saturating_sub(1)
+ .min(nr - 1);
+ assigned
+ .entry(cells.root(r * nc + c))
+ .or_default()
+ .push(w.clone());
+ selected.push(i);
+ }
+ }
+ let mut geometry = Vec::new();
+ let mut table_rows = vec![Vec::new(); nr];
+ let mut all = Vec::new();
+ let mut ordered = groups
+ .into_iter()
+ .map(|(root, slots)| {
+ let r0 = slots.iter().map(|x| x.0).min().unwrap();
+ let r1 = slots.iter().map(|x| x.0).max().unwrap();
+ let c0 = slots.iter().map(|x| x.1).min().unwrap();
+ let c1 = slots.iter().map(|x| x.1).max().unwrap();
+ (r0, c0, r1, c1, root)
+ })
+ .collect::>();
+ ordered.sort_unstable();
+ for (r, c, r1, c1, root) in ordered {
+ let mut ws = assigned.remove(&root).unwrap_or_default();
+ ws.sort_by(|a, b| a.y.total_cmp(&b.y).then(a.rect[0].total_cmp(&b.rect[0])));
+ let cs = (c1 - c + 1) as u32;
+ let rs = (r1 - r + 1) as u32;
+ table_rows[r].push(TableCell::new(inline(&ws)).with_span(cs, rs));
+ geometry.push(PdfCell {
+ row: r,
+ column: c,
+ rowspan: rs,
+ colspan: cs,
+ bbox: coords
+ .inverse()
+ .rect([xs[c], ys[r], xs[c1 + 1], ys[r1 + 1]]),
+ span_ids: ids(&ws),
+ });
+ all.extend(ws);
+ }
+ for i in selected {
+ used[i] = true;
+ }
+ let headers = table_rows.remove(0);
+ out.push(Region {
+ rect: bounds,
+ lines: rows(all),
+ node: Some(Node::Table {
+ alignments: Vec::new(),
+ headers,
+ rows: table_rows,
+ source: None,
+ }),
+ cells: geometry,
+ removed: None,
+ });
+ }
+ Ok(out)
+}
+fn numeric(s: &str) -> bool {
+ s.chars().any(|c| c.is_ascii_digit())
+ && s.chars()
+ .all(|c| c.is_ascii_digit() || c.is_whitespace() || ".,%$€£¥+-−()/:".contains(c))
+}
+fn borderless_tables(
+ rows: &mut [Vec],
+ coords: Coords,
+ cfg: &MarkkitBuilder,
+ work: &mut Work<'_>,
+) -> Result, Error> {
+ let mut out = Vec::new();
+ let mut i = 0;
+ while i < rows.len() {
+ work.tick(1)?;
+ let cols = rows[i].len();
+ if cols < 2 {
+ i += 1;
+ continue;
+ }
+ let anchors = rows[i].iter().map(|l| l.rect).collect::>();
+ let size = rows[i][0].size;
+ let mut logical = vec![rows[i].iter().cloned().map(|l| vec![l]).collect::>()];
+ let mut end = i + 1;
+ while end < rows.len() && !rows[end].is_empty() {
+ work.tick(1)?;
+ let gap = rows[end][0].y - rows[end - 1][0].y;
+ if gap > 3.0 * size {
+ break;
+ }
+ if rows[end].len() == cols
+ && rows[end].iter().zip(&anchors).all(|(l, a)| {
+ (l.rect[0] - a[0]).abs() <= 2.0 * size || (l.rect[2] - a[2]).abs() <= 2.0 * size
+ })
+ {
+ logical.push(rows[end].iter().cloned().map(|l| vec![l]).collect());
+ } else if logical.len() > 1
+ && rows[end].len() == 1
+ && gap < 1.7 * size
+ && (rows[end][0].rect[0] - anchors[0][0]).abs() < size
+ && !numeric(&rows[end][0].text())
+ {
+ logical.last_mut().unwrap()[0].push(rows[end][0].clone());
+ } else {
+ break;
+ }
+ end += 1;
+ }
+ let n = logical.len();
+ let compact = logical
+ .iter()
+ .flat_map(|r| r.iter())
+ .flatten()
+ .all(|l| l.words.len() <= 5);
+ let data = n >= 3
+ && (0..cols).any(|c| {
+ logical[1..]
+ .iter()
+ .filter(|r| numeric(&r[c].iter().map(Line::text).collect::>().join(" ")))
+ .count()
+ * 2
+ >= n - 1
+ });
+ let explicit = rows[i].iter().any(|l| {
+ l.words
+ .iter()
+ .any(|w| w.bold || w.role.as_deref() == Some("TH"))
+ });
+ if n < 3 || !compact || (!data && !explicit) {
+ i += 1;
+ continue;
+ }
+ grid_limit(n, cols, cfg)?;
+ work.clock.add_nodes(n * cols)?;
+ let mut geometry = Vec::new();
+ let mut table_rows = Vec::new();
+ let mut lines = Vec::new();
+ let mut bounds = [
+ f64::INFINITY,
+ f64::INFINITY,
+ f64::NEG_INFINITY,
+ f64::NEG_INFINITY,
+ ];
+ for (r, row) in logical.into_iter().enumerate() {
+ let mut cells = Vec::new();
+ for (c, cell_lines) in row.into_iter().enumerate() {
+ let ws = cell_lines
+ .iter()
+ .flat_map(|l| l.words.iter().cloned())
+ .collect::>();
+ let rect = bbox(ws.iter().map(|w| w.rect));
+ bounds = union(bounds, rect);
+ cells.push(TableCell::new(inline(&ws)));
+ geometry.push(PdfCell {
+ row: r,
+ column: c,
+ rowspan: 1,
+ colspan: 1,
+ bbox: coords.inverse().rect(rect),
+ span_ids: ids(&ws),
+ });
+ lines.extend(cell_lines);
+ }
+ table_rows.push(cells);
+ }
+ for row in &mut rows[i..end] {
+ row.clear();
+ }
+ let headers = table_rows.remove(0);
+ out.push(Region {
+ rect: bounds,
+ lines,
+ node: Some(Node::Table {
+ headers,
+ rows: table_rows,
+ alignments: Vec::new(),
+ source: None,
+ }),
+ cells: geometry,
+ removed: None,
+ });
+ i = end;
+ }
+ Ok(out)
+}
+
+fn order(
+ mut regions: Vec,
+ body: f64,
+ depth: usize,
+ out: &mut Vec