docling_pdf/dparse_render.rs
1//! Opt-in page-image plugin: docling-parse's Blend2D renderer, loaded at
2//! runtime (#478).
3//!
4//! docling 2.123+ renders every page image its model stages consume — the
5//! scale-1.0 layout input, the scale-2.0 TableFormer input, the OCR and
6//! enrichment crops — with docling-parse's own renderer (FreeType glyph
7//! outlines filled by Blend2D), while this pipeline renders them with its own
8//! pure-Rust renderer (`crate::render`, tiny-skia) — close to, not identical
9//! with, that canvas (mean |Δ| ≈ 1/255 over the corpus), and heron's
10//! borderline labels follow the pixels. This module lets the pipeline consume
11//! docling's raster itself: a small C shim over `renderer<BLEND2D>`
12//! (`crates/docling-pdf/ffi/docling-parse-render/dparse_render.cpp`, built by
13//! `scripts/install/build_docling_parse_render.sh`) is `dlopen`ed, and
14//! [`Doc::render`] hands back the RGBA canvas docling's `get_page_image(scale)`
15//! would. Since phase 5 of "Retiring pdfium" it is a development oracle: the
16//! conformance scripts ask for it by name, the default pipeline never loads
17//! it.
18//!
19//! Selection is an environment knob, never a build feature: nothing links the
20//! C++ side, CI and wasm are untouched, and a missing library degrades to
21//! the Rust renderer.
22//!
23//! * `DOCLING_RS_RENDERER` — `auto` (default: the pure-Rust renderer,
24//! `crate::render`; the shim is never opened), `docling-parse` (the shim —
25//! what the conformance scripts run, because the baselines in
26//! `tests/snapshots` and `docs/PDF_CONFORMANCE.md` are its renders; a
27//! missing library warns once and falls back to the Rust renderer), `rust`
28//! (the default, spelled out) or `pdfium` (the library's render, docling's
29//! pypdfium2 chain — only in a build with docling-pdf's `pdfium` feature,
30//! otherwise a one-time warning and the Rust renderer).
31//! * `DOCLING_PARSE_RENDER_LIB` — the shim library (a file, or the directory
32//! holding `libdparse_render.so`/`.dylib`); default `.docling-parse/lib`
33//! resolved like `.models` ([`crate::resolve_asset`]).
34//! * `DOCLING_PARSE_RESOURCES` — docling-parse's `pdf_resources` directory
35//! (fallback fonts, encodings, cmaps); default `<lib dir>/../pdf_resources`,
36//! where the build script installs it.
37//!
38//! The renderer is docling-parse's, so its output is compared against the
39//! Python package's `PageParseResult.get_image(scale)` byte for byte
40//! (`scripts/conformance/dparse_render_check.py`); everything downstream —
41//! the Pillow-exact 640 stretch, the TableFormer crop chain — is unchanged.
42
43use std::ffi::{c_char, c_double, c_int, c_uchar, c_void, CStr, CString};
44use std::path::{Path, PathBuf};
45use std::sync::OnceLock;
46
47use image::RgbImage;
48use libloading::{Library, Symbol};
49
50/// The C ABI the shim exports; bump together with `DPR_ABI_VERSION` there.
51/// 2: `dpr_render` takes the bitmap decode hint separately from the scale.
52const ABI_VERSION: c_int = 2;
53
54type AbiVersionFn = unsafe extern "C" fn() -> c_int;
55type VersionFn = unsafe extern "C" fn() -> *const c_char;
56type InitFn = unsafe extern "C" fn(*const c_char, *mut c_char, c_int) -> c_int;
57type OpenFn =
58 unsafe extern "C" fn(*const c_uchar, usize, *const c_char, *mut c_char, c_int) -> *mut c_void;
59type PageCountFn = unsafe extern "C" fn(*mut c_void) -> c_int;
60type RenderFn = unsafe extern "C" fn(
61 *mut c_void,
62 c_int,
63 c_double,
64 c_double,
65 *mut *mut c_uchar,
66 *mut c_int,
67 *mut c_int,
68 *mut c_char,
69 c_int,
70) -> c_int;
71type ReleasePageFn = unsafe extern "C" fn(*mut c_void, c_int);
72type FreeFn = unsafe extern "C" fn(*mut c_uchar);
73type CloseFn = unsafe extern "C" fn(*mut c_void);
74
75/// The loaded shim: the library plus its resolved entry points. One per
76/// process, behind [`plugin`].
77pub struct Plugin {
78 // Declared first so the symbols below are dropped before the library.
79 open: Symbol<'static, OpenFn>,
80 page_count: Symbol<'static, PageCountFn>,
81 render: Symbol<'static, RenderFn>,
82 release_page: Symbol<'static, ReleasePageFn>,
83 free: Symbol<'static, FreeFn>,
84 close: Symbol<'static, CloseFn>,
85 /// docling-parse's version the shim was built against (`DPR_VERSION`).
86 pub docling_parse_version: String,
87 /// Where the library was loaded from.
88 pub path: PathBuf,
89 _lib: &'static Library,
90}
91
92const ERR_LEN: usize = 1024;
93
94fn err_string(buf: &[c_char]) -> String {
95 // The shim always NUL-terminates within the buffer.
96 let bytes: Vec<u8> = buf
97 .iter()
98 .take_while(|&&c| c != 0)
99 .map(|&c| c as u8)
100 .collect();
101 String::from_utf8_lossy(&bytes).into_owned()
102}
103
104/// What `DOCLING_RS_RENDERER` asks for.
105#[derive(Clone, Copy, PartialEq, Eq, Debug)]
106pub enum Choice {
107 /// The pure-Rust renderer ([`crate::render`]) — the default. The shim is
108 /// a development oracle since phase 5 of "Retiring pdfium": it is loaded
109 /// only when asked for by name.
110 Auto,
111 /// docling-parse's renderer (the shim), warning when it is unavailable —
112 /// what the conformance scripts run, the renderer the baselines are
113 /// pinned to.
114 DoclingParse,
115 /// The pure-Rust renderer, spelled out.
116 Rust,
117 /// pdfium (the renderer of docling's pypdfium2 backend); only offered by
118 /// a build with the `pdfium` feature.
119 Pdfium,
120}
121
122/// The renderer `DOCLING_RS_RENDERER` selects; unset, empty or `auto` is
123/// [`Choice::Auto`], an unknown value warns once and counts as `auto`.
124pub fn choice() -> Choice {
125 match docling_core::env::nonempty("DOCLING_RS_RENDERER") {
126 None => Choice::Auto,
127 Some(v) => match v.trim().to_ascii_lowercase().as_str() {
128 "auto" | "" => Choice::Auto,
129 "docling-parse" | "docling_parse" | "dparse" => Choice::DoclingParse,
130 "rust" => Choice::Rust,
131 #[cfg(feature = "pdfium")]
132 "pdfium" => Choice::Pdfium,
133 #[cfg(not(feature = "pdfium"))]
134 "pdfium" => {
135 static WARNED: std::sync::Once = std::sync::Once::new();
136 WARNED.call_once(|| {
137 eprintln!(
138 "docling-pdf: DOCLING_RS_RENDERER=pdfium but pdfium support is not compiled in \
139 (docling-pdf feature `pdfium`); rendering with the Rust renderer"
140 );
141 });
142 Choice::Auto
143 }
144 other => {
145 eprintln!(
146 "docling-pdf: unknown DOCLING_RS_RENDERER={other:?} (auto | docling-parse | rust | pdfium); using auto"
147 );
148 Choice::Auto
149 }
150 },
151 }
152}
153
154/// Is the docling-parse renderer explicitly requested?
155pub fn requested() -> bool {
156 choice() == Choice::DoclingParse
157}
158
159fn platform_lib_name() -> &'static str {
160 if cfg!(target_os = "macos") {
161 "libdparse_render.dylib"
162 } else if cfg!(target_os = "windows") {
163 "dparse_render.dll"
164 } else {
165 "libdparse_render.so"
166 }
167}
168
169/// The shim library path: `DOCLING_PARSE_RENDER_LIB` (file or directory),
170/// else `.docling-parse/lib/<platform name>` resolved like the other assets.
171fn lib_path() -> PathBuf {
172 let candidate = docling_core::env::nonempty("DOCLING_PARSE_RENDER_LIB")
173 .map(PathBuf::from)
174 .unwrap_or_else(|| PathBuf::from(crate::resolve_asset(".docling-parse/lib")));
175 if candidate.is_dir() {
176 candidate.join(platform_lib_name())
177 } else {
178 candidate
179 }
180}
181
182/// docling-parse's `pdf_resources`: `DOCLING_PARSE_RESOURCES`, else the
183/// `pdf_resources` sibling of the library's `lib/` directory.
184fn resources_dir(lib: &Path) -> Option<PathBuf> {
185 if let Some(dir) = docling_core::env::nonempty("DOCLING_PARSE_RESOURCES") {
186 return Some(PathBuf::from(dir));
187 }
188 let sibling = lib.parent()?.parent()?.join("pdf_resources");
189 sibling.is_dir().then_some(sibling)
190}
191
192fn load() -> Result<Plugin, String> {
193 let path = lib_path();
194 // SAFETY: loading a library runs its initializers; the shim's are the C++
195 // runtime's and docling-parse's static state, which the shim guards.
196 let lib = unsafe { Library::new(&path) }
197 .map_err(|e| format!("cannot load {}: {e}", path.display()))?;
198 let lib: &'static Library = Box::leak(Box::new(lib));
199 // SAFETY: every symbol is declared with the shim's exact C signature.
200 unsafe {
201 let abi: Symbol<AbiVersionFn> = lib
202 .get(b"dpr_abi_version\0")
203 .map_err(|e| format!("{}: {e}", path.display()))?;
204 let got = abi();
205 if got != ABI_VERSION {
206 return Err(format!(
207 "{}: shim ABI {got}, this build expects {ABI_VERSION} — rebuild it with \
208 scripts/install/build_docling_parse_render.sh",
209 path.display()
210 ));
211 }
212 let version: Symbol<VersionFn> = lib
213 .get(b"dpr_docling_parse_version\0")
214 .map_err(|e| format!("{}: {e}", path.display()))?;
215 let docling_parse_version = CStr::from_ptr(version()).to_string_lossy().into_owned();
216 let init: Symbol<InitFn> = lib
217 .get(b"dpr_init\0")
218 .map_err(|e| format!("{}: {e}", path.display()))?;
219 let resources = resources_dir(&path)
220 .map(|p| CString::new(p.to_string_lossy().into_owned()).unwrap_or_default());
221 let mut err = [0 as c_char; ERR_LEN];
222 let rc = init(
223 resources
224 .as_ref()
225 .map(|c| c.as_ptr())
226 .unwrap_or(std::ptr::null()),
227 err.as_mut_ptr(),
228 ERR_LEN as c_int,
229 );
230 if rc != 0 {
231 return Err(format!("dpr_init: {}", err_string(&err)));
232 }
233 Ok(Plugin {
234 open: lib.get(b"dpr_open\0").map_err(|e| e.to_string())?,
235 page_count: lib.get(b"dpr_page_count\0").map_err(|e| e.to_string())?,
236 render: lib.get(b"dpr_render\0").map_err(|e| e.to_string())?,
237 release_page: lib.get(b"dpr_release_page\0").map_err(|e| e.to_string())?,
238 free: lib.get(b"dpr_free\0").map_err(|e| e.to_string())?,
239 close: lib.get(b"dpr_close\0").map_err(|e| e.to_string())?,
240 docling_parse_version,
241 path,
242 _lib: lib,
243 })
244 }
245}
246
247/// The plugin when `DOCLING_RS_RENDERER=docling-parse` asks for it and the
248/// shim loads; `None` otherwise — under `auto` the shim is never opened
249/// (the Rust renderer is the default), and an unavailable library under
250/// `docling-parse` warns once and falls back to the Rust renderer
251/// (degradation over failure).
252pub fn plugin() -> Option<&'static Plugin> {
253 static PLUGIN: OnceLock<Option<Plugin>> = OnceLock::new();
254 PLUGIN
255 .get_or_init(|| {
256 let choice = choice();
257 if choice != Choice::DoclingParse {
258 return None;
259 }
260 match load() {
261 Ok(p) => {
262 docling_core::debug_log!(
263 "docling-pdf: rendering page images with docling-parse {} ({})",
264 p.docling_parse_version,
265 p.path.display()
266 );
267 Some(p)
268 }
269 Err(e) if choice == Choice::DoclingParse => {
270 eprintln!(
271 "docling-pdf: DOCLING_RS_RENDERER=docling-parse but the renderer plugin \
272 is unavailable ({e}); rendering with the Rust renderer"
273 );
274 None
275 }
276 Err(e) => {
277 // Unreachable while only `docling-parse` opens the shim;
278 // kept for the day `auto` asks again.
279 docling_core::debug_log!(
280 "docling-pdf: docling-parse renderer plugin not loaded ({e}); rendering with the Rust renderer"
281 );
282 None
283 }
284 }
285 })
286 .as_ref()
287}
288
289/// Which renderer produces the model inputs in this process:
290/// `"docling-parse"`, `"rust"` or `"pdfium"` — for diagnostics
291/// (`--version`-style banners, serve health).
292pub fn active_name() -> &'static str {
293 if plugin().is_some() {
294 "docling-parse"
295 } else if choice() == Choice::Pdfium {
296 "pdfium"
297 } else {
298 "rust"
299 }
300}
301
302/// A PDF opened by docling-parse; renders its pages on request.
303pub struct Doc {
304 plugin: &'static Plugin,
305 handle: *mut c_void,
306}
307
308// SAFETY: the shim serializes every call behind one mutex and the handle is
309// only ever used through it; the pipeline renders on one thread anyway.
310unsafe impl Send for Doc {}
311
312impl Doc {
313 /// Open `bytes` with the plugin when it is active; `None` when the pipeline
314 /// should render with pdfium. An open failure warns and returns `None` too,
315 /// so a document docling-parse rejects still converts.
316 pub fn open_if_enabled(bytes: &[u8], password: Option<&str>) -> Option<Doc> {
317 let plugin = plugin()?;
318 match Doc::open(plugin, bytes, password) {
319 Ok(doc) => Some(doc),
320 Err(e) => {
321 eprintln!("docling-pdf: docling-parse could not open the document ({e}); rendering with the Rust renderer");
322 None
323 }
324 }
325 }
326
327 fn open(plugin: &'static Plugin, bytes: &[u8], password: Option<&str>) -> Result<Doc, String> {
328 let password = password
329 .map(|p| CString::new(p).map_err(|e| e.to_string()))
330 .transpose()?;
331 let mut err = [0 as c_char; ERR_LEN];
332 // SAFETY: the byte slice outlives the call (the shim copies it), the
333 // error buffer is NUL-terminated by the shim.
334 let handle = unsafe {
335 (plugin.open)(
336 bytes.as_ptr(),
337 bytes.len(),
338 password
339 .as_ref()
340 .map(|c| c.as_ptr())
341 .unwrap_or(std::ptr::null()),
342 err.as_mut_ptr(),
343 ERR_LEN as c_int,
344 )
345 };
346 if handle.is_null() {
347 return Err(err_string(&err));
348 }
349 Ok(Doc { plugin, handle })
350 }
351
352 /// Number of pages docling-parse sees.
353 pub fn page_count(&self) -> usize {
354 // SAFETY: a live handle from `dpr_open`.
355 unsafe { (self.plugin.page_count)(self.handle) }.max(0) as usize
356 }
357
358 /// Render the 0-based `page` at `scale` pixels per point: docling-parse's
359 /// `get_image(scale)` canvas — `ceil(w·scale)` × `ceil(h·scale)`, display
360 /// orientation — with its opaque white background, so the alpha plane is
361 /// dropped and the RGB triples are returned as they are.
362 ///
363 /// `bitmap_hint` is docling-parse's `bitmap_target_pixels_per_unit`, the
364 /// resolution the JPEG/JPX decoders may reduce an oversampled embedded
365 /// image to (docling passes its `render_scale`, 1.0; `0.0` decodes at
366 /// full resolution). A page is decoded once per distinct hint.
367 pub fn render(&self, page: usize, scale: f64, bitmap_hint: f64) -> Result<RgbImage, String> {
368 let mut rgba: *mut c_uchar = std::ptr::null_mut();
369 let (mut w, mut h) = (0 as c_int, 0 as c_int);
370 let mut err = [0 as c_char; ERR_LEN];
371 // SAFETY: a live handle; the out-pointers are valid for the call and
372 // the returned buffer is `w * h * 4` bytes owned by us until `dpr_free`.
373 let rc = unsafe {
374 (self.plugin.render)(
375 self.handle,
376 page as c_int,
377 scale,
378 bitmap_hint,
379 &mut rgba,
380 &mut w,
381 &mut h,
382 err.as_mut_ptr(),
383 ERR_LEN as c_int,
384 )
385 };
386 if rc != 0 || rgba.is_null() || w <= 0 || h <= 0 {
387 return Err(err_string(&err));
388 }
389 let (w, h) = (w as u32, h as u32);
390 let n = (w as usize) * (h as usize);
391 // SAFETY: the shim wrote exactly n * 4 bytes.
392 let src = unsafe { std::slice::from_raw_parts(rgba, n * 4) };
393 let mut rgb = Vec::with_capacity(n * 3);
394 for px in src.chunks_exact(4) {
395 rgb.extend_from_slice(&px[..3]);
396 }
397 // SAFETY: the buffer came from `dpr_render`.
398 unsafe { (self.plugin.free)(rgba) };
399 RgbImage::from_raw(w, h, rgb)
400 .ok_or_else(|| "docling-parse canvas size mismatch".to_string())
401 }
402
403 /// Drop the decoded state of `page` once every scale of it was rendered.
404 pub fn release_page(&self, page: usize) {
405 // SAFETY: a live handle.
406 unsafe { (self.plugin.release_page)(self.handle, page as c_int) }
407 }
408}
409
410impl Drop for Doc {
411 fn drop(&mut self) {
412 // SAFETY: closes the handle exactly once.
413 unsafe { (self.plugin.close)(self.handle) }
414 }
415}