Skip to main content

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}