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