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