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 with one warning.
20//!
21//! * `DOCLING_RS_RENDERER` — `pdfium` (default) or `docling-parse`.
22//! * `DOCLING_PARSE_RENDER_LIB` — the shim library (a file, or the directory
23//!   holding `libdparse_render.so`/`.dylib`); default `.docling-parse/lib`
24//!   resolved like `.pdfium/lib` ([`crate::resolve_asset`]).
25//! * `DOCLING_PARSE_RESOURCES` — docling-parse's `pdf_resources` directory
26//!   (fallback fonts, encodings, cmaps); default `<lib dir>/../pdf_resources`,
27//!   where the build script installs it.
28//!
29//! The renderer is docling-parse's, so its output is compared against the
30//! Python package's `PageParseResult.get_image(scale)` byte for byte
31//! (`scripts/conformance/dparse_render_check.py`); everything downstream —
32//! the Pillow-exact 640 stretch, the TableFormer crop chain — is unchanged.
33
34use std::ffi::{c_char, c_double, c_int, c_uchar, c_void, CStr, CString};
35use std::path::{Path, PathBuf};
36use std::sync::OnceLock;
37
38use image::RgbImage;
39use libloading::{Library, Symbol};
40
41/// The C ABI the shim exports; bump together with `DPR_ABI_VERSION` there.
42const ABI_VERSION: c_int = 1;
43
44type AbiVersionFn = unsafe extern "C" fn() -> c_int;
45type VersionFn = unsafe extern "C" fn() -> *const c_char;
46type InitFn = unsafe extern "C" fn(*const c_char, *mut c_char, c_int) -> c_int;
47type OpenFn =
48    unsafe extern "C" fn(*const c_uchar, usize, *const c_char, *mut c_char, c_int) -> *mut c_void;
49type PageCountFn = unsafe extern "C" fn(*mut c_void) -> c_int;
50type RenderFn = unsafe extern "C" fn(
51    *mut c_void,
52    c_int,
53    c_double,
54    *mut *mut c_uchar,
55    *mut c_int,
56    *mut c_int,
57    *mut c_char,
58    c_int,
59) -> c_int;
60type ReleasePageFn = unsafe extern "C" fn(*mut c_void, c_int);
61type FreeFn = unsafe extern "C" fn(*mut c_uchar);
62type CloseFn = unsafe extern "C" fn(*mut c_void);
63
64/// The loaded shim: the library plus its resolved entry points. One per
65/// process, behind [`plugin`].
66pub struct Plugin {
67    // Declared first so the symbols below are dropped before the library.
68    open: Symbol<'static, OpenFn>,
69    page_count: Symbol<'static, PageCountFn>,
70    render: Symbol<'static, RenderFn>,
71    release_page: Symbol<'static, ReleasePageFn>,
72    free: Symbol<'static, FreeFn>,
73    close: Symbol<'static, CloseFn>,
74    /// docling-parse's version the shim was built against (`DPR_VERSION`).
75    pub docling_parse_version: String,
76    _lib: &'static Library,
77}
78
79const ERR_LEN: usize = 1024;
80
81fn err_string(buf: &[c_char]) -> String {
82    // The shim always NUL-terminates within the buffer.
83    let bytes: Vec<u8> = buf
84        .iter()
85        .take_while(|&&c| c != 0)
86        .map(|&c| c as u8)
87        .collect();
88    String::from_utf8_lossy(&bytes).into_owned()
89}
90
91/// Is the docling-parse renderer requested (`DOCLING_RS_RENDERER=docling-parse`)?
92pub fn requested() -> bool {
93    docling_core::env::nonempty("DOCLING_RS_RENDERER")
94        .map(|v| {
95            let v = v.trim().to_ascii_lowercase();
96            v == "docling-parse" || v == "docling_parse" || v == "dparse"
97        })
98        .unwrap_or(false)
99}
100
101fn platform_lib_name() -> &'static str {
102    if cfg!(target_os = "macos") {
103        "libdparse_render.dylib"
104    } else if cfg!(target_os = "windows") {
105        "dparse_render.dll"
106    } else {
107        "libdparse_render.so"
108    }
109}
110
111/// The shim library path: `DOCLING_PARSE_RENDER_LIB` (file or directory),
112/// else `.docling-parse/lib/<platform name>` resolved like the other assets.
113fn lib_path() -> PathBuf {
114    let candidate = docling_core::env::nonempty("DOCLING_PARSE_RENDER_LIB")
115        .map(PathBuf::from)
116        .unwrap_or_else(|| PathBuf::from(crate::resolve_asset(".docling-parse/lib")));
117    if candidate.is_dir() {
118        candidate.join(platform_lib_name())
119    } else {
120        candidate
121    }
122}
123
124/// docling-parse's `pdf_resources`: `DOCLING_PARSE_RESOURCES`, else the
125/// `pdf_resources` sibling of the library's `lib/` directory.
126fn resources_dir(lib: &Path) -> Option<PathBuf> {
127    if let Some(dir) = docling_core::env::nonempty("DOCLING_PARSE_RESOURCES") {
128        return Some(PathBuf::from(dir));
129    }
130    let sibling = lib.parent()?.parent()?.join("pdf_resources");
131    sibling.is_dir().then_some(sibling)
132}
133
134fn load() -> Result<Plugin, String> {
135    let path = lib_path();
136    // SAFETY: loading a library runs its initializers; the shim's are the C++
137    // runtime's and docling-parse's static state, which the shim guards.
138    let lib = unsafe { Library::new(&path) }
139        .map_err(|e| format!("cannot load {}: {e}", path.display()))?;
140    let lib: &'static Library = Box::leak(Box::new(lib));
141    // SAFETY: every symbol is declared with the shim's exact C signature.
142    unsafe {
143        let abi: Symbol<AbiVersionFn> = lib
144            .get(b"dpr_abi_version\0")
145            .map_err(|e| format!("{}: {e}", path.display()))?;
146        let got = abi();
147        if got != ABI_VERSION {
148            return Err(format!(
149                "{}: shim ABI {got}, this build expects {ABI_VERSION} — rebuild it with \
150                 scripts/install/build_docling_parse_render.sh",
151                path.display()
152            ));
153        }
154        let version: Symbol<VersionFn> = lib
155            .get(b"dpr_docling_parse_version\0")
156            .map_err(|e| format!("{}: {e}", path.display()))?;
157        let docling_parse_version = CStr::from_ptr(version()).to_string_lossy().into_owned();
158        let init: Symbol<InitFn> = lib
159            .get(b"dpr_init\0")
160            .map_err(|e| format!("{}: {e}", path.display()))?;
161        let resources = resources_dir(&path)
162            .map(|p| CString::new(p.to_string_lossy().into_owned()).unwrap_or_default());
163        let mut err = [0 as c_char; ERR_LEN];
164        let rc = init(
165            resources
166                .as_ref()
167                .map(|c| c.as_ptr())
168                .unwrap_or(std::ptr::null()),
169            err.as_mut_ptr(),
170            ERR_LEN as c_int,
171        );
172        if rc != 0 {
173            return Err(format!("dpr_init: {}", err_string(&err)));
174        }
175        Ok(Plugin {
176            open: lib.get(b"dpr_open\0").map_err(|e| e.to_string())?,
177            page_count: lib.get(b"dpr_page_count\0").map_err(|e| e.to_string())?,
178            render: lib.get(b"dpr_render\0").map_err(|e| e.to_string())?,
179            release_page: lib.get(b"dpr_release_page\0").map_err(|e| e.to_string())?,
180            free: lib.get(b"dpr_free\0").map_err(|e| e.to_string())?,
181            close: lib.get(b"dpr_close\0").map_err(|e| e.to_string())?,
182            docling_parse_version,
183            _lib: lib,
184        })
185    }
186}
187
188/// The plugin when `DOCLING_RS_RENDERER=docling-parse` and the shim loads;
189/// `None` otherwise. A requested-but-unloadable plugin warns once and the
190/// pipeline keeps rendering with pdfium (degradation over failure).
191pub fn plugin() -> Option<&'static Plugin> {
192    static PLUGIN: OnceLock<Option<Plugin>> = OnceLock::new();
193    PLUGIN
194        .get_or_init(|| {
195            if !requested() {
196                return None;
197            }
198            match load() {
199                Ok(p) => {
200                    docling_core::debug_log!(
201                        "docling-pdf: rendering page images with docling-parse {} (DOCLING_RS_RENDERER)",
202                        p.docling_parse_version
203                    );
204                    Some(p)
205                }
206                Err(e) => {
207                    eprintln!(
208                        "docling-pdf: DOCLING_RS_RENDERER=docling-parse but the renderer plugin \
209                         is unavailable ({e}); rendering with pdfium"
210                    );
211                    None
212                }
213            }
214        })
215        .as_ref()
216}
217
218/// A PDF opened by docling-parse; renders its pages on request.
219pub struct Doc {
220    plugin: &'static Plugin,
221    handle: *mut c_void,
222}
223
224// SAFETY: the shim serializes every call behind one mutex and the handle is
225// only ever used through it; the pipeline renders on one thread anyway.
226unsafe impl Send for Doc {}
227
228impl Doc {
229    /// Open `bytes` with the plugin when it is active; `None` when the pipeline
230    /// should render with pdfium. An open failure warns and returns `None` too,
231    /// so a document docling-parse rejects still converts.
232    pub fn open_if_enabled(bytes: &[u8], password: Option<&str>) -> Option<Doc> {
233        let plugin = plugin()?;
234        match Doc::open(plugin, bytes, password) {
235            Ok(doc) => Some(doc),
236            Err(e) => {
237                eprintln!("docling-pdf: docling-parse could not open the document ({e}); rendering with pdfium");
238                None
239            }
240        }
241    }
242
243    fn open(plugin: &'static Plugin, bytes: &[u8], password: Option<&str>) -> Result<Doc, String> {
244        let password = password
245            .map(|p| CString::new(p).map_err(|e| e.to_string()))
246            .transpose()?;
247        let mut err = [0 as c_char; ERR_LEN];
248        // SAFETY: the byte slice outlives the call (the shim copies it), the
249        // error buffer is NUL-terminated by the shim.
250        let handle = unsafe {
251            (plugin.open)(
252                bytes.as_ptr(),
253                bytes.len(),
254                password
255                    .as_ref()
256                    .map(|c| c.as_ptr())
257                    .unwrap_or(std::ptr::null()),
258                err.as_mut_ptr(),
259                ERR_LEN as c_int,
260            )
261        };
262        if handle.is_null() {
263            return Err(err_string(&err));
264        }
265        Ok(Doc { plugin, handle })
266    }
267
268    /// Number of pages docling-parse sees.
269    pub fn page_count(&self) -> usize {
270        // SAFETY: a live handle from `dpr_open`.
271        unsafe { (self.plugin.page_count)(self.handle) }.max(0) as usize
272    }
273
274    /// Render the 0-based `page` at `scale` pixels per point: docling-parse's
275    /// `get_image(scale)` canvas — `ceil(w·scale)` × `ceil(h·scale)`, display
276    /// orientation — with its opaque white background, so the alpha plane is
277    /// dropped and the RGB triples are returned as they are.
278    pub fn render(&self, page: usize, scale: f64) -> Result<RgbImage, String> {
279        let mut rgba: *mut c_uchar = std::ptr::null_mut();
280        let (mut w, mut h) = (0 as c_int, 0 as c_int);
281        let mut err = [0 as c_char; ERR_LEN];
282        // SAFETY: a live handle; the out-pointers are valid for the call and
283        // the returned buffer is `w * h * 4` bytes owned by us until `dpr_free`.
284        let rc = unsafe {
285            (self.plugin.render)(
286                self.handle,
287                page as c_int,
288                scale,
289                &mut rgba,
290                &mut w,
291                &mut h,
292                err.as_mut_ptr(),
293                ERR_LEN as c_int,
294            )
295        };
296        if rc != 0 || rgba.is_null() || w <= 0 || h <= 0 {
297            return Err(err_string(&err));
298        }
299        let (w, h) = (w as u32, h as u32);
300        let n = (w as usize) * (h as usize);
301        // SAFETY: the shim wrote exactly n * 4 bytes.
302        let src = unsafe { std::slice::from_raw_parts(rgba, n * 4) };
303        let mut rgb = Vec::with_capacity(n * 3);
304        for px in src.chunks_exact(4) {
305            rgb.extend_from_slice(&px[..3]);
306        }
307        // SAFETY: the buffer came from `dpr_render`.
308        unsafe { (self.plugin.free)(rgba) };
309        RgbImage::from_raw(w, h, rgb)
310            .ok_or_else(|| "docling-parse canvas size mismatch".to_string())
311    }
312
313    /// Drop the decoded state of `page` once every scale of it was rendered.
314    pub fn release_page(&self, page: usize) {
315        // SAFETY: a live handle.
316        unsafe { (self.plugin.release_page)(self.handle, page as c_int) }
317    }
318}
319
320impl Drop for Doc {
321    fn drop(&mut self) {
322        // SAFETY: closes the handle exactly once.
323        unsafe { (self.plugin.close)(self.handle) }
324    }
325}