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 page_with_contents(contents: &[&[u8]]) -> Vec<u8> {
448        let mut doc = Document::with_version("1.5");
449        let streams = contents
450            .iter()
451            .map(|content| {
452                Object::Reference(doc.add_object(Stream::new(Dictionary::new(), content.to_vec())))
453            })
454            .collect::<Vec<_>>();
455        synth(
456            &mut doc,
457            [0.0, 0.0, 200.0, 100.0],
458            dictionary! { "Contents" => streams },
459            Dictionary::new(),
460            "",
461        )
462    }
463
464    fn render(bytes: &[u8]) -> RgbImage {
465        let meta = PdfMeta::open(bytes).expect("object model");
466        render_page(&meta, 0, 1.0).expect("render")
467    }
468
469    fn px(img: &RgbImage, x: u32, y: u32) -> [u8; 3] {
470        img.get_pixel(x, y).0
471    }
472
473    fn near(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
474        a.iter()
475            .zip(b)
476            .all(|(&x, y)| (i32::from(x) - i32::from(y)).abs() <= tol)
477    }
478
479    const WHITE: [u8; 3] = [255, 255, 255];
480    const RED: [u8; 3] = [255, 0, 0];
481
482    /// Ink (anything not white) inside a device rectangle.
483    fn ink_in(img: &RgbImage, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
484        let mut n = 0;
485        for y in y0..y1 {
486            for x in x0..x1 {
487                if px(img, x, y) != WHITE {
488                    n += 1;
489                }
490            }
491        }
492        n
493    }
494
495    #[test]
496    fn fills_land_in_the_docling_parse_frame() {
497        // A 200 × 100 page: canvas 200 × 100, y down, `re f` at (60, 10)
498        // 40 × 30 covers x 60..100, y 60..90.
499        let img = render(&page(Dictionary::new(), "1 0 0 rg 60 10 40 30 re f"));
500        assert_eq!((img.width(), img.height()), (200, 100));
501        assert_eq!(px(&img, 80, 75), RED);
502        assert_eq!(px(&img, 61, 61), RED);
503        assert_eq!(px(&img, 80, 55), WHITE);
504        assert_eq!(px(&img, 105, 75), WHITE);
505        assert_eq!(px(&img, 5, 5), WHITE);
506    }
507
508    #[test]
509    fn leading_comment_content_stream_does_not_hide_later_streams() {
510        let bytes = page_with_contents(&[
511            b"% CANON_PFINF_TYPE2_TEXTOFF\n",
512            b"1 0 0 rg 60 10 40 30 re f",
513        ]);
514        let img = render(&bytes);
515        assert_eq!(px(&img, 80, 75), RED);
516    }
517
518    #[test]
519    fn crop_box_is_the_canvas() {
520        // CropBox [50 0 150 100] → a 100 × 100 canvas; the rectangle at
521        // x 60..100 lands at canvas x 10..50.
522        let mut doc = Document::with_version("1.5");
523        let bytes = synth(
524            &mut doc,
525            [0.0, 0.0, 200.0, 100.0],
526            dictionary! { "CropBox" => vec![50.into(), 0.into(), 150.into(), 100.into()] },
527            Dictionary::new(),
528            "1 0 0 rg 60 10 40 30 re f",
529        );
530        let img = render(&bytes);
531        assert_eq!((img.width(), img.height()), (100, 100));
532        assert_eq!(px(&img, 30, 75), RED);
533        assert_eq!(px(&img, 5, 75), WHITE);
534        assert_eq!(px(&img, 55, 75), WHITE);
535    }
536
537    #[test]
538    fn rotate_transposes_the_canvas() {
539        // /Rotate 90: the display canvas is 100 × 200 and the page's
540        // bottom-left corner shows top-left.
541        let mut doc = Document::with_version("1.5");
542        let bytes = synth(
543            &mut doc,
544            [0.0, 0.0, 200.0, 100.0],
545            dictionary! { "Rotate" => 90 },
546            Dictionary::new(),
547            "1 0 0 rg 0 0 40 20 re f",
548        );
549        let img = render(&bytes);
550        assert_eq!((img.width(), img.height()), (100, 200));
551        assert_eq!(px(&img, 10, 20), RED);
552        assert_eq!(px(&img, 10, 45), WHITE);
553        assert_eq!(px(&img, 90, 190), WHITE);
554        let meta = PdfMeta::open(&bytes).unwrap();
555        let sized = render_page_sized(&meta, 0, 50, 100).unwrap();
556        assert_eq!((sized.width(), sized.height()), (50, 100));
557        assert_eq!(px(&sized, 5, 10), RED);
558    }
559
560    #[test]
561    fn hairline_strokes_are_one_pixel_wide() {
562        // `0 w` is a hairline in PDF; docling-parse draws it at one pixel.
563        let img = render(&page(Dictionary::new(), "0 w 10 50.5 m 190 50.5 l S"));
564        assert!(ink_in(&img, 100, 49, 101, 51) >= 1);
565        assert_eq!(ink_in(&img, 100, 40, 101, 48), 0);
566        assert_eq!(ink_in(&img, 100, 52, 101, 60), 0);
567        // A 4-pt line is four pixels tall.
568        let img = render(&page(Dictionary::new(), "4 w 10 50 m 190 50 l S"));
569        assert!(ink_in(&img, 100, 44, 101, 56) >= 4);
570        assert_eq!(ink_in(&img, 100, 40, 101, 47), 0);
571    }
572
573    #[test]
574    fn constant_alpha_blends_over_white() {
575        let res = dictionary! {
576            "ExtGState" => dictionary! {
577                "GS" => dictionary! { "ca" => 0.5, "CA" => 0.25 },
578            },
579        };
580        let img = render(&page(res, "/GS gs 0 g 20 20 160 60 re f"));
581        let p = px(&img, 100, 50);
582        assert!(near(p, [128, 128, 128], 2), "{p:?}");
583    }
584
585    #[test]
586    fn rect_clip_cuts_the_fill() {
587        let img = render(&page(
588            Dictionary::new(),
589            "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",
590        ));
591        assert_eq!(px(&img, 50, 50), [0, 0, 255]);
592        assert_eq!(px(&img, 150, 50), WHITE);
593        // The clip ends with Q: the bottom strip is black across the page.
594        assert_eq!(px(&img, 150, 95), [0, 0, 0]);
595    }
596
597    #[test]
598    fn shape_clip_follows_the_path() {
599        // A triangle clip: the fill shows inside the triangle only.
600        let img = render(&page(
601            Dictionary::new(),
602            "q 0 0 m 200 0 l 100 100 l h W n 0 1 0 rg 0 0 200 100 re f Q",
603        ));
604        assert_eq!(px(&img, 100, 50), [0, 255, 0]);
605        assert_eq!(px(&img, 100, 90), [0, 255, 0]);
606        assert_eq!(px(&img, 5, 5), WHITE);
607        assert_eq!(px(&img, 195, 5), WHITE);
608    }
609
610    #[test]
611    fn image_xobject_is_blitted_with_its_orientation() {
612        // 2 × 2 RGB: red green / blue black, first row at the top.
613        let data = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0];
614        let mut doc = Document::with_version("1.5");
615        let im = doc.add_object(Stream::new(
616            dictionary! {
617                "Type" => "XObject", "Subtype" => "Image",
618                "Width" => 2, "Height" => 2, "BitsPerComponent" => 8,
619                "ColorSpace" => "DeviceRGB",
620            },
621            data,
622        ));
623        let bytes = synth(
624            &mut doc,
625            [0.0, 0.0, 200.0, 100.0],
626            Dictionary::new(),
627            dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
628            "q 200 0 0 100 0 0 cm /Im0 Do Q",
629        );
630        let img = render(&bytes);
631        // Bilinear sampling at the quadrant centres: the source colours
632        // within tiny-skia's fixed-point rounding.
633        assert!(near(px(&img, 50, 25), RED, 6));
634        assert!(near(px(&img, 150, 25), [0, 255, 0], 6));
635        assert!(near(px(&img, 50, 75), [0, 0, 255], 6));
636        assert!(near(px(&img, 150, 75), [0, 0, 0], 6));
637    }
638
639    #[test]
640    fn inline_image_and_stencil_mask() {
641        // A 1 × 1 gray inline image over the left half, a 2 × 1 stencil
642        // (bit 0 paints) in the fill colour over the right half.
643        let mut content = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /CS /G /BPC 8 ID ".to_vec();
644        content.push(0x80);
645        content.extend_from_slice(
646            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 ",
647        );
648        content.push(0x40);
649        content.extend_from_slice(b" EI Q");
650        let mut doc = Document::with_version("1.5");
651        let bytes = synth_bytes(
652            &mut doc,
653            [0.0, 0.0, 200.0, 100.0],
654            Dictionary::new(),
655            Dictionary::new(),
656            &content,
657        );
658        let img = render(&bytes);
659        assert!(near(px(&img, 50, 50), [128, 128, 128], 1));
660        assert_eq!(px(&img, 125, 50), RED);
661        assert_eq!(px(&img, 175, 50), WHITE);
662    }
663
664    #[test]
665    fn axial_shading_interpolates() {
666        let res = dictionary! {
667            "Shading" => dictionary! {
668                "Sh" => dictionary! {
669                    "ShadingType" => 2, "ColorSpace" => "DeviceGray",
670                    "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
671                    "Function" => dictionary! {
672                        "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
673                        "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
674                    },
675                    "Extend" => vec![true.into(), true.into()],
676                },
677            },
678        };
679        let img = render(&page(res, "/Sh sh"));
680        assert!(px(&img, 5, 50)[0] < 40);
681        assert!(px(&img, 195, 50)[0] > 215);
682        assert!(near(px(&img, 100, 50), [128, 128, 128], 12));
683        // The same shading as a fill pattern, clipped by the path.
684        let res = dictionary! {
685            "Pattern" => dictionary! {
686                "P" => dictionary! {
687                    "PatternType" => 2,
688                    "Shading" => dictionary! {
689                        "ShadingType" => 2, "ColorSpace" => "DeviceGray",
690                        "Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
691                        "Function" => dictionary! {
692                            "FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
693                            "C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
694                        },
695                    },
696                },
697            },
698        };
699        let img = render(&page(res, "/Pattern cs /P scn 0 0 100 100 re f"));
700        assert!(px(&img, 5, 50)[0] < 40);
701        assert!(near(px(&img, 50, 50), [64, 64, 64], 12));
702        assert_eq!(px(&img, 150, 50), WHITE);
703    }
704
705    #[test]
706    fn tiling_pattern_repeats_its_cell() {
707        // A 20 × 20 cell with a 10 × 10 black square: half the area inked.
708        let mut doc = Document::with_version("1.5");
709        let pat = doc.add_object(Stream::new(
710            dictionary! {
711                "PatternType" => 1, "PaintType" => 1, "TilingType" => 1,
712                "BBox" => vec![0.into(), 0.into(), 20.into(), 20.into()],
713                "XStep" => 20, "YStep" => 20, "Resources" => Dictionary::new(),
714            },
715            b"0 g 0 0 10 10 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! { "Pattern" => dictionary! { "P" => Object::Reference(pat) } },
722            "/Pattern cs /P scn 0 0 200 100 re f",
723        );
724        let img = render(&bytes);
725        let inked = ink_in(&img, 0, 0, 200, 100);
726        assert!((4000..=6500).contains(&inked), "{inked}");
727        // The cell at the origin: its square covers x 0..10, y 90..100.
728        assert_eq!(px(&img, 5, 95), [0, 0, 0]);
729        assert_eq!(px(&img, 15, 85), WHITE);
730    }
731
732    #[test]
733    fn form_xobject_matrix_and_bbox() {
734        // The form fills far beyond its BBox; only the BBox, moved by the
735        // Matrix to x 100..150 / y 0..50, shows.
736        let mut doc = Document::with_version("1.5");
737        let fx = doc.add_object(Stream::new(
738            dictionary! {
739                "Type" => "XObject", "Subtype" => "Form",
740                "BBox" => vec![0.into(), 0.into(), 50.into(), 50.into()],
741                "Matrix" => vec![1.into(), 0.into(), 0.into(), 1.into(), 100.into(), 0.into()],
742            },
743            b"1 0 0 rg -500 -500 1000 1000 re f".to_vec(),
744        ));
745        let bytes = synth(
746            &mut doc,
747            [0.0, 0.0, 200.0, 100.0],
748            Dictionary::new(),
749            dictionary! { "XObject" => dictionary! { "Fx" => Object::Reference(fx) } },
750            "/Fx Do",
751        );
752        let img = render(&bytes);
753        assert_eq!(px(&img, 125, 75), RED);
754        assert_eq!(px(&img, 75, 75), WHITE);
755        assert_eq!(px(&img, 125, 25), WHITE);
756        assert_eq!(px(&img, 175, 75), WHITE);
757    }
758
759    #[test]
760    fn transparency_group_alpha_reaches_its_contents() {
761        // docling-parse pushes the group's `ca` down onto the contents.
762        let mut doc = Document::with_version("1.5");
763        let fx = doc.add_object(Stream::new(
764            dictionary! {
765                "Type" => "XObject", "Subtype" => "Form",
766                "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
767                "Group" => dictionary! { "S" => "Transparency", "CS" => "DeviceRGB" },
768            },
769            b"0 g 0 0 200 100 re f".to_vec(),
770        ));
771        let bytes = synth(
772            &mut doc,
773            [0.0, 0.0, 200.0, 100.0],
774            Dictionary::new(),
775            dictionary! {
776                "XObject" => dictionary! { "Fx" => Object::Reference(fx) },
777                "ExtGState" => dictionary! { "GS" => dictionary! { "ca" => 0.5 } },
778            },
779            "/GS gs /Fx Do",
780        );
781        let img = render(&bytes);
782        assert!(near(px(&img, 100, 50), [128, 128, 128], 2));
783    }
784
785    #[test]
786    fn widgets_draw_their_appearance_unless_hidden() {
787        let mut doc = Document::with_version("1.5");
788        let ap = doc.add_object(Stream::new(
789            dictionary! {
790                "Type" => "XObject", "Subtype" => "Form",
791                "BBox" => vec![0.into(), 0.into(), 10.into(), 10.into()],
792            },
793            b"1 0 0 rg 0 0 10 10 re f".to_vec(),
794        ));
795        let widget = |x0: i64, flags: i64, subtype: &str| -> Object {
796            Object::Dictionary(dictionary! {
797                "Type" => "Annot", "Subtype" => subtype, "F" => flags,
798                "Rect" => vec![x0.into(), 20.into(), (x0 + 40).into(), 60.into()],
799                "AP" => dictionary! { "N" => Object::Reference(ap) },
800            })
801        };
802        let bytes = synth(
803            &mut doc,
804            [0.0, 0.0, 200.0, 100.0],
805            dictionary! {
806                "Annots" => vec![
807                    widget(10, 4, "Widget"),
808                    widget(60, 2, "Widget"),
809                    widget(110, 4, "Square"),
810                ],
811            },
812            Dictionary::new(),
813            "",
814        );
815        let img = render(&bytes);
816        // The visible widget: BBox 10 × 10 fitted to the 40 × 40 Rect.
817        assert_eq!(px(&img, 30, 60), RED);
818        assert_eq!(px(&img, 12, 42), RED);
819        assert_eq!(px(&img, 48, 78), RED);
820        assert_eq!(px(&img, 30, 30), WHITE);
821        // Hidden, and a non-Widget annotation: not drawn.
822        assert_eq!(px(&img, 80, 60), WHITE);
823        assert_eq!(px(&img, 130, 60), WHITE);
824    }
825
826    #[test]
827    fn text_paints_glyphs_from_a_fallback_face() {
828        let style = font::fallback::style_for("Helvetica", None, None);
829        if font::fallback::face(style).is_none() {
830            eprintln!("no fallback fonts on this host — skipping");
831            return;
832        }
833        let res = dictionary! {
834            "Font" => dictionary! {
835                "F1" => dictionary! {
836                    "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
837                },
838            },
839        };
840        let img = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HELLO) Tj ET"));
841        // Cap height ~0.72 em: ink between y 100−30−29 and the baseline.
842        assert!(ink_in(&img, 20, 40, 160, 70) > 200);
843        assert_eq!(ink_in(&img, 0, 0, 200, 35), 0);
844        assert_eq!(ink_in(&img, 0, 75, 200, 100), 0);
845        // Render mode 3 (invisible) draws nothing; a `Tz` of 50 % halves
846        // the advance so the word ends earlier.
847        let img = render(&page(
848            res.clone(),
849            "BT 3 Tr /F1 40 Tf 20 30 Td (HELLO) Tj ET",
850        ));
851        assert_eq!(ink_in(&img, 0, 0, 200, 100), 0);
852        let wide = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HHHH) Tj ET"));
853        let narrow = render(&page(res, "BT /F1 40 Tf 50 Tz 20 30 Td (HHHH) Tj ET"));
854        let right = |img: &RgbImage| {
855            (0..200)
856                .rev()
857                .find(|&x| ink_in(img, x, 0, x + 1, 100) > 0)
858                .unwrap()
859        };
860        assert!(right(&wide) > right(&narrow) + 30);
861    }
862
863    #[test]
864    fn type3_glyphs_run_their_procedures() {
865        let mut doc = Document::with_version("1.5");
866        let square = doc.add_object(Stream::new(
867            Dictionary::new(),
868            b"1000 0 0 0 750 750 d1 0 0 750 750 re f".to_vec(),
869        ));
870        let font = doc.add_object(dictionary! {
871            "Type" => "Font", "Subtype" => "Type3",
872            "FontBBox" => vec![0.into(), 0.into(), 750.into(), 750.into()],
873            "FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()],
874            "CharProcs" => dictionary! { "square" => Object::Reference(square) },
875            "Encoding" => dictionary! {
876                "Type" => "Encoding",
877                "Differences" => vec![97.into(), Object::Name(b"square".to_vec())],
878            },
879            "FirstChar" => 97, "LastChar" => 97, "Widths" => vec![1000.into()],
880            "Resources" => Dictionary::new(),
881        });
882        let bytes = synth(
883            &mut doc,
884            [0.0, 0.0, 200.0, 100.0],
885            Dictionary::new(),
886            dictionary! { "Font" => dictionary! { "F1" => Object::Reference(font) } },
887            "BT /F1 40 Tf 20 20 Td 0 0 1 rg (aa) Tj ET",
888        );
889        let img = render(&bytes);
890        // Two 30 × 30 squares 40 pt apart: x 20..50 and 60..90, y 50..80.
891        assert_eq!(px(&img, 35, 65), [0, 0, 255]);
892        assert_eq!(px(&img, 75, 65), [0, 0, 255]);
893        assert_eq!(px(&img, 55, 65), WHITE);
894        assert_eq!(px(&img, 105, 65), WHITE);
895        assert_eq!(px(&img, 35, 45), WHITE);
896    }
897
898    #[test]
899    fn cmyk_follows_the_docling_parse_model() {
900        // docling-parse's CMYK model: pure K is (35, 31, 32), not black.
901        let img = render(&page(Dictionary::new(), "0 0 0 1 k 0 0 200 100 re f"));
902        assert_eq!(px(&img, 100, 50), [35, 31, 32]);
903    }
904
905    #[test]
906    fn a_renderer_reuses_its_caches_across_renders() {
907        // Two sizes of the same page through one Renderer: identical to
908        // one-off renders (the image store and font cache change nothing).
909        let data: Vec<u8> = (0..64 * 64 * 3).map(|i| (i % 251) as u8).collect();
910        let mut doc = Document::with_version("1.5");
911        let im = doc.add_object(Stream::new(
912            dictionary! {
913                "Type" => "XObject", "Subtype" => "Image",
914                "Width" => 64, "Height" => 64, "BitsPerComponent" => 8,
915                "ColorSpace" => "DeviceRGB",
916            },
917            data,
918        ));
919        let bytes = synth(
920            &mut doc,
921            [0.0, 0.0, 200.0, 100.0],
922            Dictionary::new(),
923            dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
924            "q 100 0 0 50 50 25 cm /Im0 Do Q q 20 0 0 20 0 0 cm /Im0 Do Q",
925        );
926        let meta = PdfMeta::open(&bytes).unwrap();
927        let r = Renderer::new(&meta);
928        let a1 = r.render(0, 200, 100).unwrap();
929        let a2 = r.render(0, 400, 200).unwrap();
930        let a3 = r.render(0, 200, 100).unwrap();
931        assert_eq!(
932            a1.as_raw(),
933            render_page_sized(&meta, 0, 200, 100).unwrap().as_raw()
934        );
935        assert_eq!(
936            a2.as_raw(),
937            render_page_sized(&meta, 0, 400, 200).unwrap().as_raw()
938        );
939        assert_eq!(a1.as_raw(), a3.as_raw());
940    }
941
942    /// #598: a `JPXDecode` image draws its pixels, not a mid-gray block. The
943    /// fixture is a lossless 12 × 9 gray gradient (`(x·21 + y·3) mod 256`):
944    /// dark at the left edge, bright at the right; with alpha from the
945    /// codestream only under `/SMaskInData`.
946    #[test]
947    fn jpx_image_renders_its_pixels() {
948        let jp2 = std::fs::read(
949            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/jpx/gray_12x9.jp2"),
950        )
951        .unwrap();
952        let mut doc = Document::with_version("1.5");
953        let im = doc.add_object(Stream::new(
954            dictionary! {
955                "Type" => "XObject", "Subtype" => "Image",
956                "Width" => 12, "Height" => 9,
957                "ColorSpace" => "DeviceGray", "BitsPerComponent" => 8,
958                "Filter" => "JPXDecode",
959            },
960            jp2.clone(),
961        ));
962        let bytes = synth(
963            &mut doc,
964            [0.0, 0.0, 200.0, 100.0],
965            Dictionary::new(),
966            dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
967            "q 120 0 0 90 40 5 cm /Im0 Do Q",
968        );
969        let img = render(&bytes);
970        // Image column 0 (codes 0–24) lands at device x 40–50, column 11
971        // (codes 231–255) at x 150–160; the row at device y 50 is image row 4.
972        let left = px(&img, 45, 50);
973        let right = px(&img, 155, 50);
974        assert!(left[0] < 40 && left == [left[0]; 3], "left pixel {left:?}");
975        assert!(
976            right[0] > 215 && right == [right[0]; 3],
977            "right pixel {right:?}"
978        );
979        assert_eq!(px(&img, 20, 50), WHITE, "outside the image stays white");
980
981        // The RGBA fixture: alpha is ignored without `/SMaskInData` (an
982        // opaque blit) and honoured with it (the x = 0 column is fully
983        // transparent, so the page shows through).
984        let rgba = std::fs::read(
985            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/jpx/rgba_8x6.jp2"),
986        )
987        .unwrap();
988        for (smask_in_data, transparent_left) in [(0, false), (1, true)] {
989            let mut doc = Document::with_version("1.5");
990            let im = doc.add_object(Stream::new(
991                dictionary! {
992                    "Type" => "XObject", "Subtype" => "Image",
993                    "Width" => 8, "Height" => 6,
994                    "ColorSpace" => "DeviceRGB", "BitsPerComponent" => 8,
995                    "Filter" => "JPXDecode", "SMaskInData" => smask_in_data,
996                },
997                rgba.clone(),
998            ));
999            let bytes = synth(
1000                &mut doc,
1001                [0.0, 0.0, 200.0, 100.0],
1002                Dictionary::new(),
1003                dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
1004                "q 160 0 0 60 20 20 cm /Im0 Do Q",
1005            );
1006            let img = render(&bytes);
1007            // Image column 0 → device x 20–40: colour (0, y·40, 128), alpha 0
1008            // (the bilinear blit lets a sliver of column 1, alpha 36, in).
1009            let p = px(&img, 30, 50);
1010            if transparent_left {
1011                assert!(near(p, WHITE, 3), "SMaskInData {smask_in_data}: {p:?}");
1012            } else {
1013                assert!(
1014                    p != WHITE && p[2] > 100,
1015                    "SMaskInData {smask_in_data}: {p:?}"
1016                );
1017            }
1018        }
1019    }
1020
1021    /// A JBIG2 fixture (`tests/data/jbig2`) and its reference bitmap as the
1022    /// packed 1-bit samples the filter would output (0 = black).
1023    fn jbig2_fixture(name: &str) -> Vec<u8> {
1024        std::fs::read(
1025            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1026                .join("tests/data/jbig2")
1027                .join(name),
1028        )
1029        .unwrap()
1030    }
1031
1032    fn jbig2_reference(name: &str) -> (usize, usize, Vec<u8>) {
1033        let img = ::image::load_from_memory(&jbig2_fixture(name))
1034            .unwrap()
1035            .to_luma8();
1036        let (w, h) = (img.width() as usize, img.height() as usize);
1037        let stride = w.div_ceil(8);
1038        let mut out = vec![0xffu8; stride * h];
1039        for (y, row) in img.rows().enumerate() {
1040            for (x, p) in row.enumerate() {
1041                if p.0[0] < 128 {
1042                    out[y * stride + x / 8] &= !(0x80 >> (x % 8));
1043                }
1044            }
1045        }
1046        (w, h, out)
1047    }
1048
1049    /// One page drawing `image` (an image XObject stream) over the whole of
1050    /// a 440 × 180 pt page, at `scale` px/pt.
1051    fn page_with_image(stream: Stream, content: &str) -> RgbImage {
1052        let mut doc = Document::with_version("1.5");
1053        let im = doc.add_object(stream);
1054        let bytes = synth(
1055            &mut doc,
1056            [0.0, 0.0, 440.0, 180.0],
1057            Dictionary::new(),
1058            dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
1059            content,
1060        );
1061        render(&bytes)
1062    }
1063
1064    fn non_white_share(img: &RgbImage) -> f64 {
1065        let n = img.pixels().filter(|p| p.0 != WHITE).count();
1066        n as f64 / (img.width() as f64 * img.height() as f64)
1067    }
1068
1069    /// #811: a JBIG2 `/ImageMask` stencil draws its pixels — the same page
1070    /// as the Flate and CCITT G4 (`/K -1`, the very same T.6 bytes)
1071    /// controls, pixel for pixel — where it used to draw nothing at all,
1072    /// leaving a scanned page's text out of the render.
1073    #[test]
1074    fn jbig2_stencil_renders_like_its_flate_and_ccitt_twins() {
1075        let (w, h, bits) = jbig2_reference("generic_mmr.png");
1076        let content = "q 0 0 0 rg 326 0 0 218 57 -19 cm /Im0 Do Q";
1077        let mask = |filter: Dictionary, data: Vec<u8>| {
1078            let mut d = dictionary! {
1079                "Type" => "XObject", "Subtype" => "Image",
1080                "Width" => w as i64, "Height" => h as i64,
1081                "ImageMask" => true,
1082            };
1083            for (k, v) in filter.into_iter() {
1084                d.set(k, v);
1085            }
1086            Stream::new(d, data)
1087        };
1088        let flate = page_with_image(mask(Dictionary::new(), bits.clone()), content);
1089        let jbig2 = page_with_image(
1090            mask(
1091                dictionary! { "Filter" => "JBIG2Decode" },
1092                jbig2_fixture("generic_mmr.jbig2"),
1093            ),
1094            content,
1095        );
1096        let ccitt = page_with_image(
1097            mask(
1098                dictionary! {
1099                    "Filter" => "CCITTFaxDecode",
1100                    "DecodeParms" => dictionary! { "K" => -1, "Columns" => w as i64, "Rows" => h as i64 },
1101                },
1102                jbig2_fixture("generic_mmr.g4"),
1103            ),
1104            content,
1105        );
1106        let share = non_white_share(&flate);
1107        assert!(share > 0.05 && share < 0.95, "control share {share}");
1108        assert_eq!(jbig2.as_raw(), flate.as_raw(), "JBIG2 stencil vs Flate");
1109        assert_eq!(ccitt.as_raw(), flate.as_raw(), "CCITT stencil vs Flate");
1110    }
1111
1112    /// A 1-bit `DeviceGray` JBIG2 image (not a stencil) and one whose
1113    /// symbol dictionary sits in `/JBIG2Globals` both draw their pixels,
1114    /// pixel-identical to the Flate image of the reference bitmap.
1115    #[test]
1116    fn jbig2_gray_image_and_globals_render_their_pixels() {
1117        let content = "q 326 0 0 218 57 -19 cm /Im0 Do Q";
1118        let gray = |w: usize, h: usize, extra: Dictionary, data: Vec<u8>| {
1119            let mut d = dictionary! {
1120                "Type" => "XObject", "Subtype" => "Image",
1121                "Width" => w as i64, "Height" => h as i64,
1122                "ColorSpace" => "DeviceGray", "BitsPerComponent" => 1,
1123            };
1124            for (k, v) in extra.into_iter() {
1125                d.set(k, v);
1126            }
1127            Stream::new(d, data)
1128        };
1129        let (w, h, bits) = jbig2_reference("generic_mmr.png");
1130        let flate = page_with_image(gray(w, h, Dictionary::new(), bits), content);
1131        let jbig2 = page_with_image(
1132            gray(
1133                w,
1134                h,
1135                dictionary! { "Filter" => "JBIG2Decode" },
1136                jbig2_fixture("generic_mmr.jbig2"),
1137            ),
1138            content,
1139        );
1140        assert!(non_white_share(&flate) > 0.05);
1141        assert_eq!(jbig2.as_raw(), flate.as_raw(), "JBIG2 gray vs Flate");
1142
1143        // The text region's symbols come from the globals stream.
1144        let (w, h, bits) = jbig2_reference("symbol_global.png");
1145        let flate = page_with_image(gray(w, h, Dictionary::new(), bits), content);
1146        let mut doc = Document::with_version("1.5");
1147        let globals = doc.add_object(Stream::new(
1148            Dictionary::new(),
1149            jbig2_fixture("symbol_global.globals.jbig2"),
1150        ));
1151        let im = doc.add_object(gray(
1152            w,
1153            h,
1154            dictionary! {
1155                "Filter" => "JBIG2Decode",
1156                "DecodeParms" => dictionary! { "JBIG2Globals" => Object::Reference(globals) },
1157            },
1158            jbig2_fixture("symbol_global.jbig2"),
1159        ));
1160        let bytes = synth(
1161            &mut doc,
1162            [0.0, 0.0, 440.0, 180.0],
1163            Dictionary::new(),
1164            dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
1165            content,
1166        );
1167        let with_globals = render(&bytes);
1168        assert!(non_white_share(&flate) > 0.02);
1169        assert_eq!(
1170            with_globals.as_raw(),
1171            flate.as_raw(),
1172            "JBIG2 + globals vs Flate"
1173        );
1174    }
1175
1176    /// A JBIG2 stream no decoder can read draws the documented mid-gray
1177    /// placeholder — for a stencil too (#811: a stencil used to draw
1178    /// nothing, so the page gave no sign that an image was missing).
1179    #[test]
1180    fn undecodable_jbig2_draws_the_placeholder_even_as_a_stencil() {
1181        let content = "q 0 0 0 rg 200 0 0 100 120 40 cm /Im0 Do Q";
1182        for is_mask in [true, false] {
1183            let mut d = dictionary! {
1184                "Type" => "XObject", "Subtype" => "Image",
1185                "Width" => 20, "Height" => 10, "Filter" => "JBIG2Decode",
1186            };
1187            if is_mask {
1188                d.set("ImageMask", true);
1189            } else {
1190                d.set("ColorSpace", "DeviceGray");
1191                d.set("BitsPerComponent", 1);
1192            }
1193            let img = page_with_image(Stream::new(d, b"not a JBIG2 stream".to_vec()), content);
1194            let inside = px(&img, 220, 90);
1195            assert!(
1196                near(inside, [128, 128, 128], 2),
1197                "mask {is_mask}: inside the image {inside:?}"
1198            );
1199            assert_eq!(
1200                px(&img, 50, 90),
1201                WHITE,
1202                "mask {is_mask}: outside stays white"
1203            );
1204        }
1205    }
1206}