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