Skip to content

Commit 710800b

Browse files
authored
Merge pull request #149 from artiz/claude/pages-and-referenced-images
pdf: --pages A-B window + memory-bounded referenced images (#80)
2 parents 7e52fdc + a4eabe0 commit 710800b

15 files changed

Lines changed: 774 additions & 146 deletions

File tree

CLAUDE.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# CLAUDE.md — working notes for AI-assisted sessions
2+
3+
Rust port of [docling](https://github.com/docling-project/docling): document
4+
conversion (PDF/Office/HTML/audio/video/…) to Markdown / docling-JSON / DCLX,
5+
validated for byte-for-byte conformance against upstream Python docling.
6+
7+
## Workflow rules
8+
9+
- **Every commit must be signed off by the author.** End each commit message
10+
with `Signed-off-by: name <email>`.
11+
- Claude Web: **Never open pull requests on `artiz/docling.rs`.** Push a `claude/<topic>`
12+
branch and hand back a compare link
13+
(`https://github.com/docling-project/docling.rs/compare/master...artiz:docling.rs:<branch>?expand=1`);
14+
the maintainer opens/merges PRs themself (usually into the upstream
15+
`docling-project/docling.rs`; `artiz/docling.rs` is their working fork).
16+
- One feature = one branch off fresh `origin/master`. Don't stack unrelated
17+
work.
18+
- Issue numbers (`#80`, `#138`, …) refer to `docling-project/docling.rs`
19+
issues; reference them in commit messages (`Refs #NN`).
20+
- **Slack notifications** (when the Slack MCP connector is available): post to
21+
**#claude-code** (channel ID `C0BKAHN0BSM`) when (a) a question blocks the
22+
work and needs the maintainer's answer, (b) a long task finishes — include
23+
the outcome and the compare link, (c) idle/bored — the queue is empty;
24+
suggest what to pick up next. Keep it to these events; don't narrate
25+
routine progress there.
26+
27+
## Workspace map
28+
29+
| Crate | What it is |
30+
|---|---|
31+
| `crates/docling-core` | `DoclingDocument` model, Markdown/JSON/DCLX serializers, `MarkdownStreamer`, chunkers |
32+
| `crates/docling` | `DocumentConverter` (format routing), declarative backends (`src/backend/`), streaming (`src/stream.rs`), video (`src/video.rs`) |
33+
| `crates/docling-pdf` | ML pipeline: pdfium + RT-DETR layout + TableFormer + PP-OCRv3 + enrichment (`ml` feature); pure-Rust text-layer path compiles for wasm without it |
34+
| `crates/docling-asr` | Whisper ASR: symphonia decode (audio + video containers) → log-mel → ONNX encoder/decoder |
35+
| `crates/docling-cli` | `docling-rs` binary (also `serve` subcommand behind `--features serve`) |
36+
| `crates/docling-serve` | axum HTTP conversion API (+ Dockerfile with ffmpeg) |
37+
| `crates/docling-py` / `docling-node` / `docling-wasm` | pyo3 / napi-rs / wasm-bindgen bindings — **excluded from the workspace default-members; py and wasm build from their own directories** |
38+
| `crates/docling-rag` | RAG subsystem (embedder, store, web UI) |
39+
40+
## Build & test
41+
42+
```bash
43+
cargo test --lib --tests -p docling-core -p docling -p docling-asr -p docling-serve -p docling-pdf
44+
cargo clippy --lib --tests --bins <same -p list> # keep it warning-free
45+
cargo fmt --all
46+
cargo check -p docling --no-default-features # pdf-text/wasm path
47+
(cd crates/docling-py && cargo check) # pyo3 binding
48+
(cd crates/docling-wasm && cargo check) # wasm binding
49+
```
50+
51+
- Prefer `--lib --tests` over bare `cargo test`: it skips example binaries,
52+
each of which statically links onnxruntime (~0.3–5 GB of `target/` churn).
53+
- **Disk discipline (remote container!):** `target/debug` balloons past 15 GB.
54+
When "No space left on device" hits, delete `target/debug/examples`,
55+
`target/debug/incremental`, oldest `target/debug/deps` files — deletes work
56+
even at 0 free. `CARGO_INCREMENTAL=0` helps. Old rustup toolchains and
57+
`~/.cargo/registry/cache` are also safe to drop.
58+
- Tests run with CWD = the crate dir, but shared fixtures and runtime assets
59+
live at the **repo root**. Resolve fixtures via
60+
`Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")` and gate asset-needing
61+
tests with a skip (see `crates/docling/tests/pages.rs::pdfium_ready`,
62+
`crates/docling/src/video.rs::asr_models_ready`) — CI without models/pdfium
63+
must stay green.
64+
65+
## Runtime assets & env
66+
67+
- `models/` (repo root): layout, TableFormer, OCR, ASR (`models/asr/`,
68+
presets in subdirs), enrichment, embedder. `.pdfium/lib/libpdfium.so` for
69+
page rendering. Fetch: `scripts/install/download_dependencies.sh`.
70+
- Resolution is CWD-relative with exe-dir fallback; env overrides:
71+
`PDFIUM_DYNAMIC_LIB_PATH`, `DOCLING_ASR_{ENCODER,DECODER,VOCAB}`,
72+
`DOCLING_FFMPEG` (video frames — ffmpeg is a runtime binary, never a build
73+
dep), `DOCLING_RS_PDF_WORKERS/_THREADS/_INTRA`, `DOCLING_RS_FP32`,
74+
`DOCLING_RS_EP` (GPU execution providers), `DOCLING_RS_ASR_LANG`,
75+
`DOCLING_CHUNK_TOKENIZER`.
76+
77+
## Conformance & fixtures
78+
79+
- `tests/data/<format>/sources/` + `groundtruth/` (+ `groundtruth_dclx/`,
80+
`-enriched/`): the corpus mirrored from upstream docling. Declarative
81+
formats must match Python docling **byte-for-byte**; the ML pipeline is
82+
pinned by deterministic snapshots (`tests/snapshots/`,
83+
`scripts/conformance/`, see `docs/PDF_CONFORMANCE.md`).
84+
- Output-regression suite: `crates/docling/tests/regression.rs` over
85+
`crates/docling/tests/data`; regenerate intentional changes with
86+
`DOCLING_RS_REGEN=1`.
87+
- When touching serializers, keep the streaming and buffered paths
88+
byte-identical — `MarkdownStreamer` tests assert exactly that.
89+
90+
## Conventions that keep recurring
91+
92+
- Options plumb through **every** surface in one PR: lib builder on
93+
`DocumentConverter` → CLI flag → serve option (multipart field + JSON body +
94+
query param) → Python kwarg → Node option struct. Grep `video_frames` or
95+
`page_range` for the full pattern.
96+
- Degradation over failure: a missing optional tool/model (ffmpeg, enrichment
97+
model) warns and degrades; only "nothing convertible at all" errors.
98+
- Docs live in `README.md` (user-facing) + `docs/MIGRATION.md` (parity table
99+
with real conformance numbers) — update both with behavior changes;
100+
`docs/PDF_CONFORMANCE.md` for pipeline/model changes.
101+
- Rust 1.96, edition 2021, `cargo fmt` + clippy clean; comments explain *why*
102+
(docling parity, perf tradeoffs), matching the existing dense doc-comment
103+
style.

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -350,13 +350,21 @@ it cancels the work. Concatenating every chunk is **byte-identical** to the
350350
buffered `export_to_markdown()`.
351351

352352
Streaming is Markdown-only — JSON serializes docling-core's reference-based tree
353-
and needs every node up front. Picture placeholders and `embedded` data-URI
354-
images stream; the `referenced` mode writes sidecar files, so it stays on the
355-
buffered `export_to_markdown_with_images` path. Use
356-
`convert_streaming_images(source, ImageMode::Embedded)` to pick the image mode.
353+
and needs every node up front. Every image mode streams
354+
(`convert_streaming_images(source, mode)` picks it): placeholders and `embedded`
355+
data URIs render inline, and `referenced` (issue #80) writes each page's image
356+
files under the converter's `artifacts_dir` **as that page's Markdown is
357+
emitted**, then drops the bytes — an image-heavy PDF holds ~one page of images
358+
in memory instead of all of them until export.
359+
360+
`--pages A-B` (issue #80; also `Pipeline::pages` /
361+
`DocumentConverter::page_range`, `pages` in serve/Node, `page_range=` in
362+
Python) converts only that 1-based inclusive PDF page window. Out-of-window
363+
pages are skipped *before* rasterization, so 3 pages of a 500-page PDF cost 3
364+
pages; `B` past the end clamps, and a window that selects nothing is an error.
357365

358366
The CLI streams Markdown by default (`--no-stream` opts back into buffering;
359-
`--to json` and `--images referenced` always buffer). `--no-table-former` skips
367+
`--to json` always buffers). `--no-table-former` skips
360368
loading/running the TableFormer table-structure model, falling back to simple
361369
geometric table reconstruction from cell positions — no model load, no
362370
per-table inference, which can noticeably speed up parsing (especially in

crates/docling-cli/src/main.rs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
//! The docling.rs counterpart of `docling.cli.main`; `docling-rs serve`
44
//! (with `--features serve`) starts the HTTP conversion API.
55
//!
6-
//! Usage: docling-rs [--strict] [--to md|json] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] [--no-ocr] [--asr-model PRESET] [--video-frames N] [--use-web-browser] [--enrich-picture-classes] [--enrich-code] [--enrich-formula] <input-file>
6+
//! Usage: docling-rs [--strict] [--to md|json] [--pages A-B] [--images MODE] [--fetch-images] [--no-stream] [--no-table-former] [--no-ocr] [--asr-model PRESET] [--video-frames N] [--use-web-browser] [--enrich-picture-classes] [--enrich-code] [--enrich-formula] <input-file>
77
//! --to md|json output format (default: md). `json` emits docling-core's
88
//! native DoclingDocument JSON (export_to_dict).
9+
//! --pages A-B convert only PDF pages A through B (1-based, inclusive;
10+
//! a single page number also works). Skipped pages are
11+
//! never rasterized, so a small window over a huge PDF is
12+
//! cheap. Non-PDF inputs ignore this.
913
//! --images MODE picture handling for Markdown (mirrors docling's
1014
//! image_mode): placeholder (default) | embedded | referenced.
11-
//! `referenced` writes image files under ./artifacts/.
12-
//! JSON always embeds extracted images as data URIs.
15+
//! `referenced` writes image files under ./artifacts/ —
16+
//! streamed to disk page by page, so image-heavy PDFs stay
17+
//! memory-bounded. JSON always embeds extracted images as
18+
//! data URIs.
1319
//! --fetch-images for HTML/EPUB, resolve external <img src> (data: URIs,
1420
//! local files, http(s) URLs, EPUB archive entries) and embed
1521
//! the bytes. Off by default; fetches over the network.
@@ -87,6 +93,7 @@ fn main() -> ExitCode {
8793
let mut enrich_code = false;
8894
let mut enrich_formula = false;
8995
let mut bench_warm: Option<usize> = None;
96+
let mut pages: Option<(usize, usize)> = None;
9097
let mut path: Option<String> = None;
9198
let mut args = std::env::args().skip(1);
9299
while let Some(arg) = args.next() {
@@ -111,6 +118,18 @@ fn main() -> ExitCode {
111118
// 0 = transcript only). Default 8.
112119
"--video-frames" => video_frames = args.next().and_then(|v| v.parse().ok()),
113120
"--images" => images = args.next().unwrap_or_default(),
121+
// PDF page window, 1-based inclusive: `--pages 3-7` or `--pages 3`.
122+
"--pages" => match args.next().as_deref().map(docling::parse_page_range) {
123+
Some(Ok(range)) => pages = Some(range),
124+
Some(Err(e)) => {
125+
eprintln!("error: --pages: {e}");
126+
return ExitCode::from(2);
127+
}
128+
None => {
129+
eprintln!("error: --pages needs a range like 1-10 (or a single page)");
130+
return ExitCode::from(2);
131+
}
132+
},
114133
// Hidden benchmarking aid: load the PDF/image pipeline once, then time
115134
// N warm conversions (models already loaded), printing the avg seconds
116135
// per conversion to stdout. This is the startup-excluded counterpart to
@@ -192,13 +211,17 @@ fn main() -> ExitCode {
192211
if let Some(max) = video_frames {
193212
converter = converter.video_frames(max);
194213
}
214+
if let Some((first, last)) = pages {
215+
converter = converter.page_range(first, last);
216+
}
195217

196218
// Stream Markdown by default: print each chunk as the converter produces it
197-
// (page by page for PDF). JSON needs the whole tree, and the referenced image
198-
// mode writes sidecar files, so both keep the buffered path. `--no-stream` opts
199-
// back into buffering for the streamable cases too.
219+
// (page by page for PDF). Referenced images stream too (#80): each page's
220+
// files land under ./artifacts/ as that page is printed, so image bytes
221+
// never accumulate. JSON needs the whole tree, so it keeps the buffered
222+
// path. `--no-stream` opts back into buffering.
200223
let is_markdown = matches!(to.as_str(), "md" | "markdown");
201-
if is_markdown && image_mode != ImageMode::Referenced && !no_stream {
224+
if is_markdown && !no_stream {
202225
let stream = match converter.convert_streaming_images(source, image_mode) {
203226
Ok(s) => s,
204227
Err(e) => {
@@ -227,6 +250,9 @@ fn main() -> ExitCode {
227250
eprintln!("error: writing output: {e}");
228251
return ExitCode::FAILURE;
229252
}
253+
if image_mode == ImageMode::Referenced {
254+
eprintln!("referenced images (if any) written to ./artifacts/ as pages completed");
255+
}
230256
return ExitCode::SUCCESS;
231257
}
232258

0 commit comments

Comments
 (0)