Skip to main content

docling_pdf/render/
mod.rs

1//! The pure-Rust page renderer — phase 3 of retiring pdfium
2//! (docs/PDF_CONFORMANCE.md, "Retiring pdfium"): the raster of a page's
3//! vector content, text and images that the layout / TableFormer / OCR
4//! stages consume — the default renderer since phase 5 (docling-parse's
5//! renderer plugin draws them only under `DOCLING_RS_RENDERER=docling-parse`).
6//!
7//! It draws what docling-parse's `renderer<BLEND2D>` draws, in its frame:
8//! the page's crop box on a `ceil(width · scale)` × `ceil(height · scale)`
9//! white canvas (`resolve_canvas_size`), y down, the box mapped onto the
10//! whole canvas, `/Rotate` applied to the finished pixels; content-stream
11//! semantics as in [`content`]; fonts as in [`font`]; the anti-aliased
12//! rasterizer, strokes, masks, gradients and image blits are tiny-skia's.
13//! It is not byte-identical to that renderer — Blend2D's font engine,
14//! rasterizer and JIT compositor round differently — and the docling-parse
15//! shim stays the reference the PDF baselines are pinned to; this renderer
16//! is measured against it (`tests::against_the_docling_parse_shim`).
17
18pub mod color;
19pub mod content;
20pub mod font;
21pub mod function;
22pub mod geom;
23pub mod image;
24pub mod objects;
25pub mod prepass;
26
27use std::rc::Rc;
28
29use ::image::RgbImage;
30use lopdf::Document;
31
32use crate::pdf_meta::PdfMeta;
33use geom::Mat;
34
35/// docling-parse's `pixels_for_extent`: `ceil(extent − 1e-6)`.
36pub fn pixels_for_extent(extent: f64) -> u32 {
37    ((extent - 1e-6).ceil().max(1.0)) as u32
38}
39
40/// A document's renderer: the per-document caches (fonts, CMYK table,
41/// decoded image samples) that its page renders share. The pipeline renders
42/// every page at two scales; hold one `Renderer` per document across both.
43pub struct Renderer<'a> {
44    meta: &'a PdfMeta,
45    shared: Rc<content::Shared>,
46}
47
48impl<'a> Renderer<'a> {
49    /// A renderer decoding its JPEGs for docling's bitmap hint (1.0 pixel per
50    /// PDF unit — the model-input renders).
51    pub fn new(meta: &'a PdfMeta) -> Renderer<'a> {
52        Renderer {
53            meta,
54            shared: Rc::new(content::Shared::default()),
55        }
56    }
57
58    /// A renderer with an explicit `bitmap_target_pixels_per_unit`: `0.0`
59    /// decodes every image at full size (the page rasters a caller keeps —
60    /// `render_pages`, the VLM input — where docling-parse is asked with 0.0
61    /// too).
62    pub fn with_bitmap_hint(meta: &'a PdfMeta, bitmap_hint: f64) -> Renderer<'a> {
63        Renderer {
64            meta,
65            shared: Rc::new(content::Shared::with_bitmap_hint(bitmap_hint)),
66        }
67    }
68
69    /// Render page `index` at `scale` pixels per point the way docling-parse
70    /// sizes its canvas (display orientation, `/Rotate` applied).
71    pub fn render_scaled(&self, index: usize, scale: f64) -> Option<RgbImage> {
72        let geom = self.meta.geometry(index)?;
73        let w = pixels_for_extent(f64::from(geom.width) * scale);
74        let h = pixels_for_extent(f64::from(geom.height) * scale);
75        self.render(index, w, h)
76    }
77
78    /// Render page `index` into exactly `width` × `height` display-frame
79    /// pixels (the crop box stretched onto the canvas, as every renderer
80    /// here does).
81    pub fn render(&self, index: usize, width: u32, height: u32) -> Option<RgbImage> {
82        self.render_with_hint(index, width, height, self.shared.bitmap_hint())
83    }
84
85    /// [`Renderer::render`] with an explicit `bitmap_target_pixels_per_unit`
86    /// for this one render: `1.0` decodes JPEGs the way docling's model
87    /// inputs are decoded, `0.0` at full size — what a bitmap kept for OCR
88    /// wants (the recognizer reads a scan's 300 dpi, not a quarter of it
89    /// blitted up; docling's own OCR runs on its hint-1.0 decode, the
90    /// deliberate deviation the pdfium raster of a scan already makes).
91    pub fn render_with_hint(
92        &self,
93        index: usize,
94        width: u32,
95        height: u32,
96        bitmap_hint: f64,
97    ) -> Option<RgbImage> {
98        if width == 0 || height == 0 || width > 1 << 15 || height > 1 << 15 {
99            return None;
100        }
101        let meta = self.meta;
102        let doc = meta.doc();
103        let pid = meta.page_id(index)?;
104        let page = doc.get_object(pid).ok()?.as_dict().ok()?;
105        let geom = meta.geometry(index)?;
106        let pb = crate::textparse::page_box(doc, pid);
107        // Draw in the unrotated frame; the canvas is transposed for 90°/270°.
108        let (cw, ch) = if geom.rotation == 90 || geom.rotation == 270 {
109            (height, width)
110        } else {
111            (width, height)
112        };
113        let sx = f64::from(cw) / f64::from(pb.w).max(1e-6);
114        let sy = f64::from(ch) / f64::from(pb.h).max(1e-6);
115        // user (x, y) → canvas ((x − l)·sx, H − (y − b)·sy)
116        let base = Mat::new(
117            sx,
118            0.0,
119            0.0,
120            -sy,
121            -f64::from(pb.l) * sx,
122            f64::from(ch) + f64::from(pb.b) * sy,
123        );
124
125        let content = doc.get_page_content(pid);
126        let resources = page_resources(doc, pid);
127        let mut interp = content::Interp::new(doc, cw, ch, self.shared.clone(), bitmap_hint)?;
128        crate::timing::timed("render.content", || {
129            interp.run_page(&content, resources, base);
130            interp.run_widgets(page, base);
131        });
132        let canvas = interp.into_canvas();
133        let rgb = crate::timing::timed("render.finish", || to_rgb(&canvas));
134        Some(rotate(rgb, geom.rotation))
135    }
136}
137
138/// One-off [`Renderer::render_scaled`] (no cache carried across pages).
139pub fn render_page(meta: &PdfMeta, index: usize, scale: f64) -> Option<RgbImage> {
140    Renderer::new(meta).render_scaled(index, scale)
141}
142
143/// One-off [`Renderer::render`].
144pub fn render_page_sized(
145    meta: &PdfMeta,
146    index: usize,
147    width: u32,
148    height: u32,
149) -> Option<RgbImage> {
150    Renderer::new(meta).render(index, width, height)
151}
152
153fn page_resources(doc: &Document, pid: lopdf::ObjectId) -> Option<&lopdf::Dictionary> {
154    doc.get_page_resources(pid).ok().and_then(|(inline, ids)| {
155        inline.or_else(|| ids.into_iter().find_map(|id| doc.get_dictionary(id).ok()))
156    })
157}
158
159/// Premultiplied RGBA over an opaque white canvas → RGB (the alpha is 255
160/// everywhere the canvas started white; a blend mode can only lower colour).
161fn to_rgb(pm: &tiny_skia::Pixmap) -> RgbImage {
162    let (w, h) = (pm.width(), pm.height());
163    let mut out = RgbImage::new(w, h);
164    let data = pm.data();
165    for (i, px) in out.pixels_mut().enumerate() {
166        let a = u32::from(data[i * 4 + 3]);
167        let un = |c: u8| -> u8 {
168            if a == 0 {
169                255
170            } else if a == 255 {
171                c
172            } else {
173                // Un-premultiply, then composite over white.
174                let c = u32::from(c);
175                (c + (255 - a)).min(255) as u8
176            }
177        };
178        *px = ::image::Rgb([un(data[i * 4]), un(data[i * 4 + 1]), un(data[i * 4 + 2])]);
179    }
180    out
181}
182
183/// Rotate the finished canvas clockwise by `/Rotate` degrees.
184fn rotate(img: RgbImage, rotation: u16) -> RgbImage {
185    match rotation {
186        90 => ::image::imageops::rotate90(&img),
187        180 => ::image::imageops::rotate180(&img),
188        270 => ::image::imageops::rotate270(&img),
189        _ => img,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn root() -> std::path::PathBuf {
198        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
199    }
200
201    /// Every corpus page renders at scale 1.0 to the docling-parse canvas
202    /// size, with ink on it.
203    #[test]
204    fn renders_the_corpus() {
205        // Every PDF the snapshot corpus is generated from — the pdf fixtures,
206        // the scans, the LaTeX figure PDFs (matplotlib / TikZ vector art,
207        // where the renderer's biggest snapshot drifts were) and the ODF
208        // renders; `DOCLING_RS_SHIM_CORPUS=pdf` restricts it to the pdf dir.
209        let mut files = Vec::new();
210        for d in corpus_dirs() {
211            collect_pdfs(&root().join(d), &mut files);
212        }
213        files.sort();
214        assert!(!files.is_empty());
215        for f in files {
216            let bytes = std::fs::read(&f).unwrap();
217            let Some(meta) = PdfMeta::open(&bytes) else {
218                continue;
219            };
220            let renderer = Renderer::new(&meta);
221            for i in 0..meta.page_count().min(3) {
222                let g = meta.geometry(i).unwrap();
223                let img = renderer
224                    .render_scaled(i, 1.0)
225                    .unwrap_or_else(|| panic!("{}: page {} declined", f.display(), i + 1));
226                assert_eq!(
227                    img.width(),
228                    pixels_for_extent(f64::from(g.width)),
229                    "{}",
230                    f.display()
231                );
232                assert_eq!(
233                    img.height(),
234                    pixels_for_extent(f64::from(g.height)),
235                    "{}",
236                    f.display()
237                );
238                let dark = img.pixels().filter(|p| p[0] < 200).count();
239                assert!(dark > 0, "{} p{}: blank render", f.display(), i + 1);
240            }
241        }
242    }
243
244    /// Measure this renderer against the docling-parse shim on the corpus at
245    /// the two pipeline scales, when the shim is installed: prints per-file
246    /// mean absolute differences and asserts a loose bound so a regression
247    /// (a page gone blank, text drawn at the wrong size) fails the test.
248    #[test]
249    fn against_the_docling_parse_shim() {
250        // Tests run with CWD = the crate; point the loader at the repo's copy.
251        if std::env::var_os("DOCLING_PARSE_RENDER_LIB").is_none() {
252            let lib = root().join(".docling-parse/lib");
253            if lib.is_dir() {
254                std::env::set_var("DOCLING_PARSE_RENDER_LIB", &lib);
255            }
256        }
257        // The shim loads only when asked for by name (phase 5); no other test
258        // in this binary resolves the plugin, so the once-only load sees this.
259        std::env::set_var("DOCLING_RS_RENDERER", "docling-parse");
260        let Some(plugin) = crate::dparse_render::plugin() else {
261            eprintln!("docling-parse shim not installed — skipping the renderer comparison");
262            return;
263        };
264        let _ = plugin;
265        // Every PDF the snapshot corpus is generated from (the walk of
266        // `renders_the_corpus`), so the gate covers what the baselines see.
267        let mut files = Vec::new();
268        for d in corpus_dirs() {
269            collect_pdfs(&root().join(d), &mut files);
270        }
271        files.sort();
272        eprintln!("renderer vs shim: {} PDFs", files.len());
273        let mut worst = 0.0f64;
274        let mut total_mad = 0.0f64;
275        let mut n = 0usize;
276        for f in &files {
277            let bytes = std::fs::read(f).unwrap();
278            let Some(meta) = PdfMeta::open(&bytes) else {
279                eprintln!("{}: object model unreadable, skipped", f.display());
280                continue;
281            };
282            let renderer = Renderer::new(&meta);
283            let Some(dp) = crate::dparse_render::Doc::open_if_enabled(&bytes, None) else {
284                eprintln!("{}: the shim declined the file, skipped", f.display());
285                continue;
286            };
287            for i in 0..meta.page_count() {
288                for scale in [1.0f64, 2.0] {
289                    let Ok(want) = dp.render(i, scale, 1.0) else {
290                        continue;
291                    };
292                    let Some(got) = renderer.render(i, want.width(), want.height()) else {
293                        continue;
294                    };
295                    let mad = mean_abs_diff(&got, &want);
296                    worst = worst.max(mad);
297                    total_mad += mad;
298                    n += 1;
299                    eprintln!(
300                        "{} p{} @{scale}: mean |Δ| = {mad:.2}",
301                        f.file_name().unwrap().to_string_lossy(),
302                        i + 1
303                    );
304                }
305                dp.release_page(i);
306            }
307        }
308        if n > 0 {
309            eprintln!(
310                "renderer vs shim: {n} renders, mean |Δ| {:.2}, worst {worst:.2}",
311                total_mad / n as f64
312            );
313        }
314        // The corpus sits at mean |Δ| ≈ 1 / 255 per channel with the worst
315        // page (a photograph resampled through a different bilinear phase)
316        // under 6; a page past 10 is a drawing bug, not rounding.
317        assert!(
318            worst < 10.0,
319            "a render diverged from the shim (mean |Δ| {worst:.1})"
320        );
321        if n > 0 {
322            assert!(
323                total_mad / (n as f64) < 2.0,
324                "the corpus drifted from the shim"
325            );
326        }
327    }
328
329    /// The source directories of the snapshot corpus; `DOCLING_RS_SHIM_CORPUS=pdf`
330    /// restricts a run to the pdf fixtures.
331    fn corpus_dirs() -> &'static [&'static str] {
332        if std::env::var("DOCLING_RS_SHIM_CORPUS").as_deref() == Ok("pdf") {
333            &["tests/data/pdf/sources"]
334        } else {
335            &[
336                "tests/data/pdf/sources",
337                "tests/data/scanned/sources",
338                "tests/data/latex/sources",
339                "tests/data/odf/sources",
340            ]
341        }
342    }
343
344    fn collect_pdfs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
345        let Ok(rd) = std::fs::read_dir(dir) else {
346            return;
347        };
348        for e in rd.flatten() {
349            let p = e.path();
350            if p.is_dir() {
351                collect_pdfs(&p, out);
352            } else if p.extension().is_some_and(|e| e == "pdf") {
353                out.push(p);
354            }
355        }
356    }
357
358    fn mean_abs_diff(a: &RgbImage, b: &RgbImage) -> f64 {
359        if a.dimensions() != b.dimensions() {
360            return 255.0;
361        }
362        let sum: u64 = a
363            .as_raw()
364            .iter()
365            .zip(b.as_raw())
366            .map(|(x, y)| u64::from((i32::from(*x) - i32::from(*y)).unsigned_abs()))
367            .sum();
368        sum as f64 / a.as_raw().len().max(1) as f64
369    }
370}
371
372/// Synthetic pages, one drawing feature each: the frame mapping, `/Rotate`,
373/// the minimum stroke width, constant alpha, clips, images (XObject and
374/// inline), shadings, patterns, forms, widgets, text (fallback face and
375/// Type 3). Each asserts where the ink lands, not its exact coverage — the
376/// shim comparison above is the coverage oracle.
377#[cfg(test)]
378mod synthetic {
379    use super::*;
380    use lopdf::{dictionary, Dictionary, Object, Stream};
381
382    /// A one-page document: `media` box, `page_extra` merged into the page
383    /// dictionary (CropBox, Rotate, Annots), `resources`, `content`.
384    fn synth(
385        doc: &mut Document,
386        media: [f32; 4],
387        page_extra: Dictionary,
388        resources: Dictionary,
389        content: &str,
390    ) -> Vec<u8> {
391        synth_bytes(doc, media, page_extra, resources, content.as_bytes())
392    }
393
394    fn synth_bytes(
395        doc: &mut Document,
396        media: [f32; 4],
397        page_extra: Dictionary,
398        resources: Dictionary,
399        content: &[u8],
400    ) -> Vec<u8> {
401        let pages_id = doc.new_object_id();
402        let res_id = doc.add_object(resources);
403        let content_id = doc.add_object(Stream::new(Dictionary::new(), content.to_vec()));
404        let mut page = dictionary! {
405            "Type" => "Page",
406            "Parent" => Object::Reference(pages_id),
407            "MediaBox" => media.iter().map(|&v| Object::Real(v)).collect::<Vec<_>>(),
408            "Contents" => Object::Reference(content_id),
409            "Resources" => Object::Reference(res_id),
410        };
411        for (k, v) in page_extra.into_iter() {
412            page.set(k, v);
413        }
414        let page_id = doc.add_object(page);
415        doc.objects.insert(
416            pages_id,
417            Object::Dictionary(dictionary! {
418                "Type" => "Pages",
419                "Kids" => vec![Object::Reference(page_id)],
420                "Count" => 1,
421            }),
422        );
423        let catalog_id = doc.add_object(dictionary! {
424            "Type" => "Catalog",
425            "Pages" => Object::Reference(pages_id),
426        });
427        doc.trailer.set("Root", Object::Reference(catalog_id));
428        let mut out = Vec::new();
429        doc.save_to(&mut out).unwrap();
430        out
431    }
432
433    /// A 200 × 100 pt page with `content` and `resources`.
434    fn page(resources: Dictionary, content: &str) -> Vec<u8> {
435        let mut doc = Document::with_version("1.5");
436        synth(
437            &mut doc,
438            [0.0, 0.0, 200.0, 100.0],
439            Dictionary::new(),
440            resources,
441            content,
442        )
443    }
444
445    fn render(bytes: &[u8]) -> RgbImage {
446        let meta = PdfMeta::open(bytes).expect("object model");
447        render_page(&meta, 0, 1.0).expect("render")
448    }
449
450    fn px(img: &RgbImage, x: u32, y: u32) -> [u8; 3] {
451        img.get_pixel(x, y).0
452    }
453
454    fn near(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
455        a.iter()
456            .zip(b)
457            .all(|(&x, y)| (i32::from(x) - i32::from(y)).abs() <= tol)
458    }
459
460    const WHITE: [u8; 3] = [255, 255, 255];
461    const RED: [u8; 3] = [255, 0, 0];
462
463    /// Ink (anything not white) inside a device rectangle.
464    fn ink_in(img: &RgbImage, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
465        let mut n = 0;
466        for y in y0..y1 {
467            for x in x0..x1 {
468                if px(img, x, y) != WHITE {
469                    n += 1;
470                }
471            }
472        }
473        n
474    }
475
476    #[test]
477    fn fills_land_in_the_docling_parse_frame() {
478        // A 200 × 100 page: canvas 200 × 100, y down, `re f` at (60, 10)
479        // 40 × 30 covers x 60..100, y 60..90.
480        let img = render(&page(Dictionary::new(), "1 0 0 rg 60 10 40 30 re f"));
481        assert_eq!((img.width(), img.height()), (200, 100));
482        assert_eq!(px(&img, 80, 75), RED);
483        assert_eq!(px(&img, 61, 61), RED);
484        assert_eq!(px(&img, 80, 55), WHITE);
485        assert_eq!(px(&img, 105, 75), WHITE);
486        assert_eq!(px(&img, 5, 5), WHITE);
487    }
488
489    #[test]
490    fn crop_box_is_the_canvas() {
491        // CropBox [50 0 150 100] → a 100 × 100 canvas; the rectangle at
492        // x 60..100 lands at canvas x 10..50.
493        let mut doc = Document::with_version("1.5");
494        let bytes = synth(
495            &mut doc,
496            [0.0, 0.0, 200.0, 100.0],
497            dictionary! { "CropBox" => vec![50.into(), 0.into(), 150.into(), 100.into()] },
498            Dictionary::new(),
499            "1 0 0 rg 60 10 40 30 re f",
500        );
501        let img = render(&bytes);
502        assert_eq!((img.width(), img.height()), (100, 100));
503        assert_eq!(px(&img, 30, 75), RED);
504        assert_eq!(px(&img, 5, 75), WHITE);
505        assert_eq!(px(&img, 55, 75), WHITE);
506    }
507
508    #[test]
509    fn rotate_transposes_the_canvas() {
510        // /Rotate 90: the display canvas is 100 × 200 and the page's
511        // bottom-left corner shows top-left.
512        let mut doc = Document::with_version("1.5");
513        let bytes = synth(
514            &mut doc,
515            [0.0, 0.0, 200.0, 100.0],
516            dictionary! { "Rotate" => 90 },
517            Dictionary::new(),
518            "1 0 0 rg 0 0 40 20 re f",
519        );
520        let img = render(&bytes);
521        assert_eq!((img.width(), img.height()), (100, 200));
522        assert_eq!(px(&img, 10, 20), RED);
523        assert_eq!(px(&img, 10, 45), WHITE);
524        assert_eq!(px(&img, 90, 190), WHITE);
525        let meta = PdfMeta::open(&bytes).unwrap();
526        let sized = render_page_sized(&meta, 0, 50, 100).unwrap();
527        assert_eq!((sized.width(), sized.height()), (50, 100));
528        assert_eq!(px(&sized, 5, 10), RED);
529    }
530
531    #[test]
532    fn hairline_strokes_are_one_pixel_wide() {
533        // `0 w` is a hairline in PDF; docling-parse draws it at one pixel.
534        let img = render(&page(Dictionary::new(), "0 w 10 50.5 m 190 50.5 l S"));
535        assert!(ink_in(&img, 100, 49, 101, 51) >= 1);
536        assert_eq!(ink_in(&img, 100, 40, 101, 48), 0);
537        assert_eq!(ink_in(&img, 100, 52, 101, 60), 0);
538        // A 4-pt line is four pixels tall.
539        let img = render(&page(Dictionary::new(), "4 w 10 50 m 190 50 l S"));
540        assert!(ink_in(&img, 100, 44, 101, 56) >= 4);
541        assert_eq!(ink_in(&img, 100, 40, 101, 47), 0);
542    }
543
544    #[test]
545    fn constant_alpha_blends_over_white() {
546        let res = dictionary! {
547            "ExtGState" => dictionary! {
548                "GS" => dictionary! { "ca" => 0.5, "CA" => 0.25 },
549            },
550        };
551        let img = render(&page(res, "/GS gs 0 g 20 20 160 60 re f"));
552        let p = px(&img, 100, 50);
553        assert!(near(p, [128, 128, 128], 2), "{p:?}");
554    }
555
556    #[test]
557    fn rect_clip_cuts_the_fill() {
558        let img = render(&page(
559            Dictionary::new(),
560            "q 0 0 100 100 re W n 0 0 1 rg 0 0 200 100 re f Q 0 0 200 10 re f",
561        ));
562        assert_eq!(px(&img, 50, 50), [0, 0, 255]);
563        assert_eq!(px(&img, 150, 50), WHITE);
564        // The clip ends with Q: the bottom strip is black across the page.
565        assert_eq!(px(&img, 150, 95), [0, 0, 0]);
566    }
567
568    #[test]
569    fn shape_clip_follows_the_path() {
570        // A triangle clip: the fill shows inside the triangle only.
571        let img = render(&page(
572            Dictionary::new(),
573            "q 0 0 m 200 0 l 100 100 l h W n 0 1 0 rg 0 0 200 100 re f Q",
574        ));
575        assert_eq!(px(&img, 100, 50), [0, 255, 0]);
576        assert_eq!(px(&img, 100, 90), [0, 255, 0]);
577        assert_eq!(px(&img, 5, 5), WHITE);
578        assert_eq!(px(&img, 195, 5), WHITE);
579    }
580
581    #[test]
582    fn image_xobject_is_blitted_with_its_orientation() {
583        // 2 × 2 RGB: red green / blue black, first row at the top.
584        let data = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0];
585        let mut doc = Document::with_version("1.5");
586        let im = doc.add_object(Stream::new(
587            dictionary! {
588                "Type" => "XObject", "Subtype" => "Image",
589                "Width" => 2, "Height" => 2, "BitsPerComponent" => 8,
590                "ColorSpace" => "DeviceRGB",
591            },
592            data,
593        ));
594        let bytes = synth(
595            &mut doc,
596            [0.0, 0.0, 200.0, 100.0],
597            Dictionary::new(),
598            dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
599            "q 200 0 0 100 0 0 cm /Im0 Do Q",
600        );
601        let img = render(&bytes);
602        // Bilinear sampling at the quadrant centres: the source colours
603        // within tiny-skia's fixed-point rounding.
604        assert!(near(px(&img, 50, 25), RED, 6));
605        assert!(near(px(&img, 150, 25), [0, 255, 0], 6));
606        assert!(near(px(&img, 50, 75), [0, 0, 255], 6));
607        assert!(near(px(&img, 150, 75), [0, 0, 0], 6));
608    }
609
610    #[test]
611    fn inline_image_and_stencil_mask() {
612        // A 1 × 1 gray inline image over the left half, a 2 × 1 stencil
613        // (bit 0 paints) in the fill colour over the right half.
614        let mut content = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /CS /G /BPC 8 ID ".to_vec();
615        content.push(0x80);
616        content.extend_from_slice(
617            b" EI Q q 1 0 0 rg 100 0 0 100 100 0 cm BI /W 2 /H 1 /IM true /D [0 1] ID ",
618        );
619        content.push(0x40);
620        content.extend_from_slice(b" EI Q");
621        let mut doc = Document::with_version("1.5");
622        let bytes = synth_bytes(
623            &mut doc,
624            [0.0, 0.0, 200.0, 100.0],
625            Dictionary::new(),
626            Dictionary::new(),
627            &content,
628        );
629        let img = render(&bytes);
630        assert!(near(px(&img, 50, 50), [128, 128, 128], 1));
631        assert_eq!(px(&img, 125, 50), RED);
632        assert_eq!(px(&img, 175, 50), WHITE);
633    }
634
635    #[test]
636    fn axial_shading_interpolates() {
637        let res = dictionary! {
638            "Shading" => dictionary! {
639                "Sh" => dictionary! {
640                    "ShadingType" => 2, "ColorSpace" => "DeviceGray",
641                    "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
642                    "Function" => dictionary! {
643                        "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
644                        "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
645                    },
646                    "Extend" => vec![true.into(), true.into()],
647                },
648            },
649        };
650        let img = render(&page(res, "/Sh sh"));
651        assert!(px(&img, 5, 50)[0] < 40);
652        assert!(px(&img, 195, 50)[0] > 215);
653        assert!(near(px(&img, 100, 50), [128, 128, 128], 12));
654        // The same shading as a fill pattern, clipped by the path.
655        let res = dictionary! {
656            "Pattern" => dictionary! {
657                "P" => dictionary! {
658                    "PatternType" => 2,
659                    "Shading" => dictionary! {
660                        "ShadingType" => 2, "ColorSpace" => "DeviceGray",
661                        "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
662                        "Function" => dictionary! {
663                            "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
664                            "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
665                        },
666                    },
667                },
668            },
669        };
670        let img = render(&page(res, "/Pattern cs /P scn 0 0 100 100 re f"));
671        assert!(px(&img, 5, 50)[0] < 40);
672        assert!(near(px(&img, 50, 50), [64, 64, 64], 12));
673        assert_eq!(px(&img, 150, 50), WHITE);
674    }
675
676    #[test]
677    fn tiling_pattern_repeats_its_cell() {
678        // A 20 × 20 cell with a 10 × 10 black square: half the area inked.
679        let mut doc = Document::with_version("1.5");
680        let pat = doc.add_object(Stream::new(
681            dictionary! {
682                "PatternType" => 1, "PaintType" => 1, "TilingType" => 1,
683                "BBox" => vec![0.into(), 0.into(), 20.into(), 20.into()],
684                "XStep" => 20, "YStep" => 20, "Resources" => Dictionary::new(),
685            },
686            b"0 g 0 0 10 10 re f".to_vec(),
687        ));
688        let bytes = synth(
689            &mut doc,
690            [0.0, 0.0, 200.0, 100.0],
691            Dictionary::new(),
692            dictionary! { "Pattern" => dictionary! { "P" => Object::Reference(pat) } },
693            "/Pattern cs /P scn 0 0 200 100 re f",
694        );
695        let img = render(&bytes);
696        let inked = ink_in(&img, 0, 0, 200, 100);
697        assert!((4000..=6500).contains(&inked), "{inked}");
698        // The cell at the origin: its square covers x 0..10, y 90..100.
699        assert_eq!(px(&img, 5, 95), [0, 0, 0]);
700        assert_eq!(px(&img, 15, 85), WHITE);
701    }
702
703    #[test]
704    fn form_xobject_matrix_and_bbox() {
705        // The form fills far beyond its BBox; only the BBox, moved by the
706        // Matrix to x 100..150 / y 0..50, shows.
707        let mut doc = Document::with_version("1.5");
708        let fx = doc.add_object(Stream::new(
709            dictionary! {
710                "Type" => "XObject", "Subtype" => "Form",
711                "BBox" => vec![0.into(), 0.into(), 50.into(), 50.into()],
712                "Matrix" => vec![1.into(), 0.into(), 0.into(), 1.into(), 100.into(), 0.into()],
713            },
714            b"1 0 0 rg -500 -500 1000 1000 re f".to_vec(),
715        ));
716        let bytes = synth(
717            &mut doc,
718            [0.0, 0.0, 200.0, 100.0],
719            Dictionary::new(),
720            dictionary! { "XObject" => dictionary! { "Fx" => Object::Reference(fx) } },
721            "/Fx Do",
722        );
723        let img = render(&bytes);
724        assert_eq!(px(&img, 125, 75), RED);
725        assert_eq!(px(&img, 75, 75), WHITE);
726        assert_eq!(px(&img, 125, 25), WHITE);
727        assert_eq!(px(&img, 175, 75), WHITE);
728    }
729
730    #[test]
731    fn transparency_group_alpha_reaches_its_contents() {
732        // docling-parse pushes the group's `ca` down onto the contents.
733        let mut doc = Document::with_version("1.5");
734        let fx = doc.add_object(Stream::new(
735            dictionary! {
736                "Type" => "XObject", "Subtype" => "Form",
737                "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
738                "Group" => dictionary! { "S" => "Transparency", "CS" => "DeviceRGB" },
739            },
740            b"0 g 0 0 200 100 re f".to_vec(),
741        ));
742        let bytes = synth(
743            &mut doc,
744            [0.0, 0.0, 200.0, 100.0],
745            Dictionary::new(),
746            dictionary! {
747                "XObject" => dictionary! { "Fx" => Object::Reference(fx) },
748                "ExtGState" => dictionary! { "GS" => dictionary! { "ca" => 0.5 } },
749            },
750            "/GS gs /Fx Do",
751        );
752        let img = render(&bytes);
753        assert!(near(px(&img, 100, 50), [128, 128, 128], 2));
754    }
755
756    #[test]
757    fn widgets_draw_their_appearance_unless_hidden() {
758        let mut doc = Document::with_version("1.5");
759        let ap = doc.add_object(Stream::new(
760            dictionary! {
761                "Type" => "XObject", "Subtype" => "Form",
762                "BBox" => vec![0.into(), 0.into(), 10.into(), 10.into()],
763            },
764            b"1 0 0 rg 0 0 10 10 re f".to_vec(),
765        ));
766        let widget = |x0: i64, flags: i64, subtype: &str| -> Object {
767            Object::Dictionary(dictionary! {
768                "Type" => "Annot", "Subtype" => subtype, "F" => flags,
769                "Rect" => vec![x0.into(), 20.into(), (x0 + 40).into(), 60.into()],
770                "AP" => dictionary! { "N" => Object::Reference(ap) },
771            })
772        };
773        let bytes = synth(
774            &mut doc,
775            [0.0, 0.0, 200.0, 100.0],
776            dictionary! {
777                "Annots" => vec![
778                    widget(10, 4, "Widget"),
779                    widget(60, 2, "Widget"),
780                    widget(110, 4, "Square"),
781                ],
782            },
783            Dictionary::new(),
784            "",
785        );
786        let img = render(&bytes);
787        // The visible widget: BBox 10 × 10 fitted to the 40 × 40 Rect.
788        assert_eq!(px(&img, 30, 60), RED);
789        assert_eq!(px(&img, 12, 42), RED);
790        assert_eq!(px(&img, 48, 78), RED);
791        assert_eq!(px(&img, 30, 30), WHITE);
792        // Hidden, and a non-Widget annotation: not drawn.
793        assert_eq!(px(&img, 80, 60), WHITE);
794        assert_eq!(px(&img, 130, 60), WHITE);
795    }
796
797    #[test]
798    fn text_paints_glyphs_from_a_fallback_face() {
799        let style = font::fallback::style_for("Helvetica", None, None);
800        if font::fallback::face(style).is_none() {
801            eprintln!("no fallback fonts on this host — skipping");
802            return;
803        }
804        let res = dictionary! {
805            "Font" => dictionary! {
806                "F1" => dictionary! {
807                    "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
808                },
809            },
810        };
811        let img = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HELLO) Tj ET"));
812        // Cap height ~0.72 em: ink between y 100−30−29 and the baseline.
813        assert!(ink_in(&img, 20, 40, 160, 70) > 200);
814        assert_eq!(ink_in(&img, 0, 0, 200, 35), 0);
815        assert_eq!(ink_in(&img, 0, 75, 200, 100), 0);
816        // Render mode 3 (invisible) draws nothing; a `Tz` of 50 % halves
817        // the advance so the word ends earlier.
818        let img = render(&page(
819            res.clone(),
820            "BT 3 Tr /F1 40 Tf 20 30 Td (HELLO) Tj ET",
821        ));
822        assert_eq!(ink_in(&img, 0, 0, 200, 100), 0);
823        let wide = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HHHH) Tj ET"));
824        let narrow = render(&page(res, "BT /F1 40 Tf 50 Tz 20 30 Td (HHHH) Tj ET"));
825        let right = |img: &RgbImage| {
826            (0..200)
827                .rev()
828                .find(|&x| ink_in(img, x, 0, x + 1, 100) > 0)
829                .unwrap()
830        };
831        assert!(right(&wide) > right(&narrow) + 30);
832    }
833
834    #[test]
835    fn type3_glyphs_run_their_procedures() {
836        let mut doc = Document::with_version("1.5");
837        let square = doc.add_object(Stream::new(
838            Dictionary::new(),
839            b"1000 0 0 0 750 750 d1 0 0 750 750 re f".to_vec(),
840        ));
841        let font = doc.add_object(dictionary! {
842            "Type" => "Font", "Subtype" => "Type3",
843            "FontBBox" => vec![0.into(), 0.into(), 750.into(), 750.into()],
844            "FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()],
845            "CharProcs" => dictionary! { "square" => Object::Reference(square) },
846            "Encoding" => dictionary! {
847                "Type" => "Encoding",
848                "Differences" => vec![97.into(), Object::Name(b"square".to_vec())],
849            },
850            "FirstChar" => 97, "LastChar" => 97, "Widths" => vec![1000.into()],
851            "Resources" => Dictionary::new(),
852        });
853        let bytes = synth(
854            &mut doc,
855            [0.0, 0.0, 200.0, 100.0],
856            Dictionary::new(),
857            dictionary! { "Font" => dictionary! { "F1" => Object::Reference(font) } },
858            "BT /F1 40 Tf 20 20 Td 0 0 1 rg (aa) Tj ET",
859        );
860        let img = render(&bytes);
861        // Two 30 × 30 squares 40 pt apart: x 20..50 and 60..90, y 50..80.
862        assert_eq!(px(&img, 35, 65), [0, 0, 255]);
863        assert_eq!(px(&img, 75, 65), [0, 0, 255]);
864        assert_eq!(px(&img, 55, 65), WHITE);
865        assert_eq!(px(&img, 105, 65), WHITE);
866        assert_eq!(px(&img, 35, 45), WHITE);
867    }
868
869    #[test]
870    fn cmyk_follows_the_docling_parse_model() {
871        // docling-parse's CMYK model: pure K is (35, 31, 32), not black.
872        let img = render(&page(Dictionary::new(), "0 0 0 1 k 0 0 200 100 re f"));
873        assert_eq!(px(&img, 100, 50), [35, 31, 32]);
874    }
875
876    #[test]
877    fn a_renderer_reuses_its_caches_across_renders() {
878        // Two sizes of the same page through one Renderer: identical to
879        // one-off renders (the image store and font cache change nothing).
880        let data: Vec<u8> = (0..64 * 64 * 3).map(|i| (i % 251) as u8).collect();
881        let mut doc = Document::with_version("1.5");
882        let im = doc.add_object(Stream::new(
883            dictionary! {
884                "Type" => "XObject", "Subtype" => "Image",
885                "Width" => 64, "Height" => 64, "BitsPerComponent" => 8,
886                "ColorSpace" => "DeviceRGB",
887            },
888            data,
889        ));
890        let bytes = synth(
891            &mut doc,
892            [0.0, 0.0, 200.0, 100.0],
893            Dictionary::new(),
894            dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
895            "q 100 0 0 50 50 25 cm /Im0 Do Q q 20 0 0 20 0 0 cm /Im0 Do Q",
896        );
897        let meta = PdfMeta::open(&bytes).unwrap();
898        let r = Renderer::new(&meta);
899        let a1 = r.render(0, 200, 100).unwrap();
900        let a2 = r.render(0, 400, 200).unwrap();
901        let a3 = r.render(0, 200, 100).unwrap();
902        assert_eq!(
903            a1.as_raw(),
904            render_page_sized(&meta, 0, 200, 100).unwrap().as_raw()
905        );
906        assert_eq!(
907            a2.as_raw(),
908            render_page_sized(&meta, 0, 400, 200).unwrap().as_raw()
909        );
910        assert_eq!(a1.as_raw(), a3.as_raw());
911    }
912}