Skip to main content

docling_pdf/raster/
mod.rs

1//! Pure-Rust raster of an *image-only* page — a scan: one or a few `/Image`
2//! XObjects blitted onto a white page — reproducing pdfium's bitmap byte for
3//! byte, so the OCR path needs no `libpdfium` for the pages it runs on.
4//!
5//! Phase 2 of retiring pdfium (docs/PDF_CONFORMANCE.md, "Retiring pdfium").
6//! The page is accepted only when everything pdfium would do to render it is
7//! something this module does identically, and declined (`None`, the caller
8//! falls back to pdfium or the docling-parse plugin) otherwise:
9//!
10//! * the content stream — through Form XObjects, with their `/Matrix` and
11//!   `/BBox` — draws nothing but images: `q`/`Q`/`cm`, rectangular `re W n`
12//!   clips, ExtGStates that change nothing visible, invisible (`3 Tr`) text,
13//!   fill colours in DeviceGray/DeviceRGB (for stencil masks); a painted
14//!   path, shading, inline image, non-rectangular clip, visible text, or a
15//!   soft mask/blend/alpha declines the page;
16//! * each image is 1/2/4/8-bit DeviceGray/CalGray, 8-bit
17//!   DeviceRGB/CalRGB/sRGB-ICC, Indexed over one of those, or a 1-bit
18//!   `/ImageMask` stencil, without `/SMask`/`/Mask`, in a filter chain this
19//!   module decodes (Flate/LZW/RunLength/ASCII with predictors, `DCTDecode`
20//!   through [`jpeg`] — at pdfium's reduced DCT scale when the image is at
21//!   least twice the bitmap — and `CCITTFaxDecode` through [`fax`]); JPX,
22//!   JBIG2, CMYK/Lab/Separation and non-sRGB ICC images stay with pdfium;
23//! * the image matrix is axis-aligned or a 90° rotation — pdfium's
24//!   `CFX_AggImageRenderer` stretch paths, both ported in [`stretch`]; a
25//!   general affine placement (its `CFX_ImageTransformer`) declines.
26//!
27//! The device geometry is pdfium's to the float: the display matrix of
28//! `CPDF_Page::GetDisplayMatrix`, the CTM chain of the content parser
29//! (`cm` prepends, forms concatenate), `GetUnitRect().GetOuterRect()` for
30//! the image's device rectangle, the rectangular-clip shortcut of
31//! `CFX_AggDeviceDriver::SetClip_PathFill`, the flips and swapped clip of a
32//! 90° placement, and the image's own decode/palette rules of `CPDF_DIB`
33//! (`/Decode` and Indexed lookups become the 2ⁿ-entry palette pdfium
34//! builds — applied before the stretch for 2–8-bit samples, after it through
35//! the 256-step ramp for 1-bit ones — a stencil mask is stretched to 8-bit
36//! coverage and merged with the fill colour as `CompositeRow_ByteMask2Rgb`
37//! does). The oracle is pdfium itself: the tests compare against
38//! `render_with_config` on the scanned fixtures and on synthesized pages
39//! covering every image kind, when the library is installed.
40
41pub mod fax;
42pub mod filters;
43pub use docling_core::jpeg;
44#[cfg(test)]
45mod jpeg_tests;
46pub mod stretch;
47
48use std::collections::HashMap;
49
50use image::RgbImage;
51use lopdf::{Dictionary, Document, Object, ObjectId};
52
53use crate::pdf_meta::PdfMeta;
54use stretch::{Options, Rect, Source};
55
56/// The renderer knob for image-only pages: `DOCLING_RS_SCAN_RASTER`.
57/// `rust` (default) takes this module's bitmap whenever the page qualifies;
58/// `pdfium` never does — an A/B switch for conformance runs.
59pub fn enabled() -> bool {
60    !matches!(
61        docling_core::env::nonempty("DOCLING_RS_SCAN_RASTER").as_deref(),
62        Some("pdfium") | Some("off") | Some("0")
63    )
64}
65
66/// Render page `index` into a `width` × `height` bitmap the way pdfium's
67/// `FPDF_RenderPageBitmap` (white-cleared, `FPDF_ANNOT`) would, if the page
68/// is image-only in the sense above. `None` — with the reason under
69/// `DOCLING_RS_DEBUG` — when it is not, or something in it is unsupported.
70pub fn render(meta: &PdfMeta, index: usize, width: u32, height: u32) -> Option<RgbImage> {
71    match render_inner(meta, index, width, height) {
72        Ok(img) => Some(img),
73        Err(why) => {
74            docling_core::debug_log!(
75                "docling-pdf: page {}: not rendered by the Rust raster ({why})",
76                index + 1
77            );
78            None
79        }
80    }
81}
82
83/// pdfium's `CFX_Matrix`: `f32`, applied as `(a·x + c·y + e, b·x + d·y + f)`.
84#[derive(Clone, Copy, Debug, PartialEq)]
85struct M {
86    a: f32,
87    b: f32,
88    c: f32,
89    d: f32,
90    e: f32,
91    f: f32,
92}
93
94impl M {
95    const ID: M = M {
96        a: 1.0,
97        b: 0.0,
98        c: 0.0,
99        d: 1.0,
100        e: 0.0,
101        f: 0.0,
102    };
103
104    /// `CFX_Matrix::operator*(right)`: `self` first, then `right`.
105    fn then(self, r: M) -> M {
106        M {
107            a: self.a * r.a + self.b * r.c,
108            b: self.a * r.b + self.b * r.d,
109            c: self.c * r.a + self.d * r.c,
110            d: self.c * r.b + self.d * r.d,
111            e: self.e * r.a + self.f * r.c + r.e,
112            f: self.e * r.b + self.f * r.d + r.f,
113        }
114    }
115
116    fn apply(self, x: f32, y: f32) -> (f32, f32) {
117        (
118            self.a * x + self.c * y + self.e,
119            self.b * x + self.d * y + self.f,
120        )
121    }
122
123    fn from_array(v: &[f32]) -> Option<M> {
124        if v.len() != 6 || v.iter().any(|x| !x.is_finite()) {
125            return None;
126        }
127        Some(M {
128            a: v[0],
129            b: v[1],
130            c: v[2],
131            d: v[3],
132            e: v[4],
133            f: v[5],
134        })
135    }
136}
137
138/// `CFX_FloatRect` after `TransformRect`/`Normalize`: y-up naming, but the
139/// values are device coordinates here.
140#[derive(Clone, Copy, Debug)]
141struct FRect {
142    left: f32,
143    bottom: f32,
144    right: f32,
145    top: f32,
146}
147
148impl FRect {
149    fn from_points(pts: &[(f32, f32)]) -> FRect {
150        let mut r = FRect {
151            left: pts[0].0,
152            right: pts[0].0,
153            bottom: pts[0].1,
154            top: pts[0].1,
155        };
156        for &(x, y) in &pts[1..] {
157            r.left = r.left.min(x);
158            r.right = r.right.max(x);
159            r.bottom = r.bottom.min(y);
160            r.top = r.top.max(y);
161        }
162        r
163    }
164
165    fn normalize(&mut self) {
166        if self.left > self.right {
167            std::mem::swap(&mut self.left, &mut self.right);
168        }
169        if self.bottom > self.top {
170            std::mem::swap(&mut self.bottom, &mut self.top);
171        }
172    }
173
174    /// `CFX_FloatRect::Intersect`: an empty intersection is the zero rect.
175    fn intersect(&mut self, o: FRect) {
176        self.normalize();
177        let mut o = o;
178        o.normalize();
179        self.left = self.left.max(o.left);
180        self.bottom = self.bottom.max(o.bottom);
181        self.right = self.right.min(o.right);
182        self.top = self.top.min(o.top);
183        if self.left > self.right || self.bottom > self.top {
184            *self = FRect {
185                left: 0.0,
186                bottom: 0.0,
187                right: 0.0,
188                top: 0.0,
189            };
190        }
191    }
192
193    /// `GetOuterRect`: floor the low edges, ceil the high ones (y down).
194    fn outer(&self) -> Rect {
195        let mut r = Rect::new(
196            sat(self.left.floor()),
197            sat(self.bottom.floor()),
198            sat(self.right.ceil()),
199            sat(self.top.ceil()),
200        );
201        r.normalize();
202        r
203    }
204}
205
206fn sat(v: f32) -> i32 {
207    if v.is_nan() {
208        0
209    } else {
210        v.clamp(i32::MIN as f32, i32::MAX as f32) as i32
211    }
212}
213
214/// `FXSYS_roundf(clamp(v, 0, 1) · 255)`: a colour component to a byte the
215/// way `CPDF_Color::GetColorRef` and `CPDF_DIB::LoadPalette` do it.
216fn to_byte(v: f32) -> u8 {
217    (v.clamp(0.0, 1.0) * 255.0).round() as u8
218}
219
220/// One image placement in device space.
221struct Draw {
222    stream: ObjectId,
223    /// Image unit square → device.
224    matrix: M,
225    /// Device clip at the time of `Do` (integer, from the rectangular clips).
226    clip: Rect,
227    /// The fill colour at `Do` (a stencil mask paints with it); `None` when
228    /// it was set through a colour space this module does not convert.
229    fill: Option<[u8; 3]>,
230}
231
232/// The fill colour space `sc`/`scn` operands are read in.
233#[derive(Clone, Copy, PartialEq, Eq)]
234enum FillCs {
235    Gray,
236    Rgb,
237    Other,
238}
239
240/// The graphics state the walk tracks (`CPDF_AllStates` subset).
241#[derive(Clone, Copy)]
242struct GState {
243    ctm: M,
244    clip: Rect,
245    /// `Tr` — only invisible text (3) is tolerated.
246    text_render: i64,
247    /// pdfium's `fill_color_ref_` (black by default); `None` once a colour
248    /// this module cannot reproduce (CMYK, patterns, …) was set.
249    fill: Option<[u8; 3]>,
250    fill_cs: FillCs,
251}
252
253/// Rendering context of one content stream: the `mtObj2Device` its objects
254/// are rendered with (the display matrix for the page, `F * parent` for a
255/// form), the resources and the page resources pdfium falls back to.
256struct Ctx<'a> {
257    doc: &'a Document,
258    obj2dev: M,
259    res: Option<&'a Dictionary>,
260    page_res: Option<&'a Dictionary>,
261    device: Rect,
262    depth: usize,
263}
264
265struct Walker {
266    draws: Vec<Draw>,
267    ops: usize,
268}
269
270const MAX_OPS: usize = 500_000;
271const MAX_DEPTH: usize = 12;
272
273fn deref<'a>(doc: &'a Document, obj: &'a Object) -> &'a Object {
274    match obj {
275        Object::Reference(id) => doc.get_object(*id).unwrap_or(obj),
276        o => o,
277    }
278}
279
280fn as_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
281    match deref(doc, obj) {
282        Object::Dictionary(d) => Some(d),
283        Object::Stream(s) => Some(&s.dict),
284        _ => None,
285    }
286}
287
288fn num(o: &Object) -> Option<f32> {
289    match o {
290        Object::Integer(i) => Some(*i as f32),
291        Object::Real(r) => Some(*r),
292        _ => None,
293    }
294}
295
296fn nums(ops: &[Object]) -> Option<Vec<f32>> {
297    ops.iter().map(num).collect()
298}
299
300fn name_is(o: Option<&Object>, n: &[u8]) -> bool {
301    matches!(o, Some(Object::Name(v)) if v == n)
302}
303
304fn bool_or_int_true(doc: &Document, o: Option<&Object>) -> bool {
305    match o.map(|o| deref(doc, o)) {
306        Some(Object::Boolean(b)) => *b,
307        Some(Object::Integer(i)) => *i != 0,
308        Some(Object::Real(r)) => *r != 0.0,
309        _ => false,
310    }
311}
312
313/// A resource of `kind`/`name`: the stream's own resources, then the page's
314/// (`CPDF_StreamContentParser::FindResourceHolder`).
315fn find_resource<'a>(ctx: &Ctx<'a>, kind: &[u8], name: &[u8]) -> Option<&'a Object> {
316    [ctx.res, ctx.page_res]
317        .into_iter()
318        .flatten()
319        .find_map(|res| {
320            res.get(kind)
321                .ok()
322                .and_then(|o| as_dict(ctx.doc, o))
323                .and_then(|d| d.get(name).ok())
324        })
325}
326
327/// Is this ExtGState invisible to an opaque image? Anything that could
328/// change pixels (alpha, blend, soft mask, transfer, overprint) declines.
329fn extgstate_is_neutral(doc: &Document, gs: &Dictionary) -> Result<(), String> {
330    for (k, v) in gs.iter() {
331        let v = deref(doc, v);
332        let ok = match k.as_slice() {
333            b"Type" | b"LW" | b"LC" | b"LJ" | b"ML" | b"D" | b"RI" | b"Font" | b"FL" | b"SM"
334            | b"SA" | b"OPM" | b"TK" => true,
335            b"CA" | b"ca" => num(v) == Some(1.0),
336            b"OP" | b"op" => matches!(v, Object::Boolean(false)),
337            b"BM" => match v {
338                Object::Name(n) => n == b"Normal" || n == b"Compatible",
339                Object::Array(a) => a.first().is_some_and(|o| {
340                    name_is(Some(o), b"Normal") || name_is(Some(o), b"Compatible")
341                }),
342                _ => false,
343            },
344            b"SMask" => name_is(Some(v), b"None"),
345            b"AIS" => matches!(v, Object::Boolean(false)),
346            _ => false,
347        };
348        if !ok {
349            return Err(format!("ExtGState /{}", String::from_utf8_lossy(k)));
350        }
351    }
352    Ok(())
353}
354
355/// The fill colour space a `cs` operand selects, as far as `sc` values can
356/// be converted here (`CPDF_StreamContentParser::FindColorSpace` +
357/// `CPDF_Color::GetRGB`): the device gray/RGB spaces (unless the resources
358/// override them with `/DefaultGray`/`/DefaultRGB`), CalGray (pdfium ignores
359/// its gamma), a CalRGB without `/Gamma`/`/Matrix`, the sRGB ICC profile.
360fn fill_cs_of(ctx: &Ctx<'_>, name: &[u8]) -> FillCs {
361    match name {
362        b"DeviceGray" | b"DeviceRGB" => {
363            let default: &[u8] = if name == b"DeviceGray" {
364                b"DefaultGray"
365            } else {
366                b"DefaultRGB"
367            };
368            if find_resource(ctx, b"ColorSpace", default).is_some() {
369                return FillCs::Other;
370            }
371            if name == b"DeviceGray" {
372                FillCs::Gray
373            } else {
374                FillCs::Rgb
375            }
376        }
377        b"DeviceCMYK" | b"Pattern" => FillCs::Other,
378        other => {
379            let Some(obj) = find_resource(ctx, b"ColorSpace", other) else {
380                return FillCs::Other;
381            };
382            match color_space(ctx.doc, obj, None, None) {
383                Ok(Cs::Gray) => FillCs::Gray,
384                Ok(Cs::Rgb {
385                    cal_rgb_with_transform: false,
386                }) => FillCs::Rgb,
387                _ => FillCs::Other,
388            }
389        }
390    }
391}
392
393impl Walker {
394    /// `re x y w h` under `ctm`, then `obj2dev`: pdfium's rectangular clip —
395    /// the five path points transformed twice in `f32`, still a rectangle,
396    /// intersected with the device and rounded outwards.
397    fn rect_clip(&self, ctx: &Ctx<'_>, ctm: M, re: &[f32]) -> Result<Rect, String> {
398        let (x, y, w, h) = (re[0], re[1], re[2], re[3]);
399        let user = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)];
400        let mut dev = [(0f32, 0f32); 5];
401        for (i, &(px, py)) in user.iter().enumerate() {
402            // `CPDF_StreamContentParser::AddPathObject`: the path is stored in
403            // content space (CTM applied), then `CFX_Path::GetRect(&mtObj2Device)`.
404            let (ux, uy) = ctm.apply(px, py);
405            dev[i] = ctx.obj2dev.apply(ux, uy);
406            if i > 0 && dev[i].0 != dev[i - 1].0 && dev[i].1 != dev[i - 1].1 {
407                return Err("non-rectangular clip".into());
408            }
409        }
410        if dev[0].0 != dev[3].0 && dev[0].1 != dev[3].1 {
411            return Err("non-rectangular clip".into());
412        }
413        let mut r = FRect {
414            left: dev[0].0,
415            bottom: dev[0].1,
416            right: dev[2].0,
417            top: dev[2].1,
418        };
419        r.normalize();
420        r.intersect(FRect {
421            left: 0.0,
422            bottom: 0.0,
423            right: ctx.device.right as f32,
424            top: ctx.device.bottom as f32,
425        });
426        Ok(r.outer())
427    }
428
429    fn walk<'a>(&mut self, ctx: &Ctx<'a>, content: &[u8], init: GState) -> Result<(), String> {
430        if ctx.depth > MAX_DEPTH {
431            return Err("form nesting".into());
432        }
433        let ops = lopdf::content::Content::decode(content).map_err(|e| format!("content: {e}"))?;
434        let mut stack: Vec<GState> = Vec::new();
435        let mut st = init;
436        // The current path, as far as a clip can use it: exactly one `re`.
437        let mut path_rect: Option<Vec<f32>> = None;
438        let mut path_other = false;
439        let mut clip_pending = false;
440        let mut compat = 0usize;
441        for op in &ops.operations {
442            self.ops += 1;
443            if self.ops > MAX_OPS {
444                return Err("content too long".into());
445            }
446            let o = op.operator.as_str();
447            let args = &op.operands;
448            match o {
449                "q" => {
450                    stack.push(st);
451                    if stack.len() > 256 {
452                        return Err("q nesting".into());
453                    }
454                }
455                "Q" => {
456                    if let Some(s) = stack.pop() {
457                        st = s;
458                    }
459                }
460                "cm" => {
461                    let v = nums(args).ok_or("cm operands")?;
462                    let m = M::from_array(&v).ok_or("cm matrix")?;
463                    // `prepend_to_current_transformation_matrix`: new × CTM.
464                    st.ctm = m.then(st.ctm);
465                }
466                "gs" => {
467                    let name = match args.first() {
468                        Some(Object::Name(n)) => n,
469                        _ => return Err("gs operand".into()),
470                    };
471                    let gs = find_resource(ctx, b"ExtGState", name)
472                        .and_then(|o| as_dict(ctx.doc, o))
473                        .ok_or("gs resource")?;
474                    extgstate_is_neutral(ctx.doc, gs)?;
475                }
476                // Path construction: only a lone rectangle can become a clip.
477                "re" => {
478                    let v = nums(args).ok_or("re operands")?;
479                    if v.len() != 4 {
480                        return Err("re operands".into());
481                    }
482                    if path_rect.is_some() || path_other {
483                        path_other = true;
484                    } else {
485                        path_rect = Some(v);
486                    }
487                }
488                "m" | "l" | "c" | "v" | "y" | "h" => path_other = true,
489                "W" | "W*" => clip_pending = true,
490                "n" => {
491                    if clip_pending {
492                        match (&path_rect, path_other) {
493                            (Some(re), false) => {
494                                let r = self.rect_clip(ctx, st.ctm, re)?;
495                                st.clip.intersect(&r);
496                            }
497                            _ => return Err("non-rectangular clip".into()),
498                        }
499                    }
500                    path_rect = None;
501                    path_other = false;
502                    clip_pending = false;
503                }
504                "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" | "S" | "s" => {
505                    return Err(format!("painted path ({o})"));
506                }
507                "sh" => return Err("shading".into()),
508                "BI" | "ID" | "EI" => return Err("inline image".into()),
509                "d0" | "d1" => return Err("type3 glyph".into()),
510                // Text: state is fine, showing glyphs is not unless invisible.
511                "BT" | "ET" | "Tc" | "Tw" | "Tz" | "TL" | "Tf" | "Ts" | "Td" | "TD" | "Tm"
512                | "T*" => {}
513                "Tr" => {
514                    st.text_render = args.first().and_then(|o| o.as_i64().ok()).unwrap_or(0);
515                }
516                "Tj" | "TJ" | "'" | "\"" => {
517                    if st.text_render != 3 {
518                        return Err("visible text".into());
519                    }
520                }
521                // Fill colour (a stencil mask paints with it); pdfium rounds
522                // each component to a byte when the colour is set.
523                "g" => {
524                    if let Some(v) = args.last().and_then(num) {
525                        let b = to_byte(v);
526                        st.fill = Some([b, b, b]);
527                    }
528                    st.fill_cs = FillCs::Gray;
529                }
530                "rg" => {
531                    if let Some(v) = nums(args).filter(|v| v.len() >= 3) {
532                        let n = v.len();
533                        st.fill = Some([to_byte(v[n - 3]), to_byte(v[n - 2]), to_byte(v[n - 1])]);
534                    }
535                    st.fill_cs = FillCs::Rgb;
536                }
537                "k" => {
538                    // Adobe CMYK → sRGB is a 9⁴-sample table in pdfium.
539                    st.fill = None;
540                    st.fill_cs = FillCs::Other;
541                }
542                "cs" => {
543                    // `cs` selects the space and resets the colour value but
544                    // leaves the cached colour ref alone until `sc`.
545                    st.fill_cs = match args.first() {
546                        Some(Object::Name(n)) => fill_cs_of(ctx, n),
547                        _ => FillCs::Other,
548                    };
549                }
550                "sc" | "scn" => {
551                    if args.iter().any(|a| matches!(a, Object::Name(_))) {
552                        // A pattern.
553                        st.fill = None;
554                    } else {
555                        let v: Vec<f32> = args.iter().filter_map(num).collect();
556                        let v = if v.len() > 4 {
557                            v[v.len() - 4..].to_vec()
558                        } else {
559                            v
560                        };
561                        match st.fill_cs {
562                            FillCs::Gray if !v.is_empty() => {
563                                let b = to_byte(v[0]);
564                                st.fill = Some([b, b, b]);
565                            }
566                            FillCs::Rgb if v.len() >= 3 => {
567                                st.fill = Some([to_byte(v[0]), to_byte(v[1]), to_byte(v[2])]);
568                            }
569                            // Too few operands: pdfium leaves the colour alone.
570                            FillCs::Gray | FillCs::Rgb => {}
571                            FillCs::Other => st.fill = None,
572                        }
573                    }
574                }
575                // Stroke colour, line state, marked content, compatibility: no pixels.
576                "G" | "RG" | "K" | "CS" | "SC" | "SCN" | "w" | "J" | "j" | "M" | "d" | "ri"
577                | "i" | "BMC" | "BDC" | "EMC" | "MP" | "DP" => {}
578                "BX" => compat += 1,
579                "EX" => compat = compat.saturating_sub(1),
580                "Do" => {
581                    let name = match args.first() {
582                        Some(Object::Name(n)) => n,
583                        _ => return Err("Do operand".into()),
584                    };
585                    let xobj = find_resource(ctx, b"XObject", name).ok_or("Do resource")?;
586                    let (id, stream) = match xobj {
587                        Object::Reference(id) => match ctx.doc.get_object(*id) {
588                            Ok(Object::Stream(s)) => (*id, s),
589                            _ => return Err("XObject reference".into()),
590                        },
591                        // A direct XObject stream has no id to cache under;
592                        // pdfium clones inline ones — rare enough to decline.
593                        _ => return Err("direct XObject".into()),
594                    };
595                    let subtype = stream.dict.get(b"Subtype").ok().map(|o| deref(ctx.doc, o));
596                    if name_is(subtype, b"Image") {
597                        if st.clip.is_empty() {
598                            continue;
599                        }
600                        self.draws.push(Draw {
601                            stream: id,
602                            matrix: st.ctm.then(ctx.obj2dev),
603                            clip: st.clip,
604                            fill: st.fill,
605                        });
606                    } else if name_is(subtype, b"Form") {
607                        self.form(ctx, stream, st)?;
608                    } else {
609                        return Err("XObject subtype".into());
610                    }
611                }
612                _ if compat > 0 => {}
613                other => return Err(format!("operator {other}")),
614            }
615        }
616        Ok(())
617    }
618
619    /// `Do` on a Form XObject: pdfium parses it with `/Matrix` as the initial
620    /// CTM, its `/BBox` as a clip in the form's own space, and renders its
621    /// objects with `form_matrix * mtObj2Device` where `form_matrix` is the
622    /// CTM at `Do`.
623    fn form<'a>(
624        &mut self,
625        ctx: &Ctx<'a>,
626        stream: &'a lopdf::Stream,
627        st: GState,
628    ) -> Result<(), String> {
629        let d = &stream.dict;
630        if let Some(group) = d.get(b"Group").ok().and_then(|o| as_dict(ctx.doc, o)) {
631            // An isolated or knockout transparency group renders through a
632            // separate bitmap (`ProcessTransparency`); not reproduced here.
633            if bool_or_int_true(ctx.doc, group.get(b"I").ok())
634                || bool_or_int_true(ctx.doc, group.get(b"K").ok())
635            {
636                return Err("isolated/knockout transparency group".into());
637            }
638        }
639        if d.get(b"OC").is_ok() {
640            return Err("optional content".into());
641        }
642        let form_matrix = match d.get(b"Matrix").ok().map(|o| deref(ctx.doc, o)) {
643            Some(Object::Array(a)) => {
644                let v: Vec<f32> = a.iter().filter_map(|o| num(deref(ctx.doc, o))).collect();
645                M::from_array(&v).ok_or("form /Matrix")?
646            }
647            _ => M::ID,
648        };
649        let inner = Ctx {
650            doc: ctx.doc,
651            obj2dev: st.ctm.then(ctx.obj2dev),
652            res: d
653                .get(b"Resources")
654                .ok()
655                .and_then(|o| as_dict(ctx.doc, o))
656                .or(ctx.res),
657            page_res: ctx.page_res,
658            device: ctx.device,
659            depth: ctx.depth + 1,
660        };
661        let mut inner_state = GState {
662            ctm: form_matrix,
663            ..st
664        };
665        if let Some(Object::Array(b)) = d.get(b"BBox").ok().map(|o| deref(ctx.doc, o)) {
666            let v: Vec<f32> = b.iter().filter_map(|o| num(deref(ctx.doc, o))).collect();
667            if v.len() != 4 {
668                return Err("form /BBox".into());
669            }
670            // `CPDF_Path::AppendFloatRect` of the normalized bbox, transformed
671            // by the form matrix — i.e. the rectangle `re` would build.
672            let (l, r) = (v[0].min(v[2]), v[0].max(v[2]));
673            let (bt, tp) = (v[1].min(v[3]), v[1].max(v[3]));
674            let clip = self.rect_clip(&inner, form_matrix, &[l, bt, r - l, tp - bt])?;
675            inner_state.clip.intersect(&clip);
676        }
677        if inner_state.clip.is_empty() {
678            return Ok(());
679        }
680        let content = stream
681            .decompressed_content()
682            .map_err(|e| format!("form content: {e}"))?;
683        self.walk(&inner, &content, inner_state)
684    }
685}
686
687/// A decoded image in the form pdfium's `CPDF_DIB` would present it to the
688/// stretch engine.
689struct Decoded {
690    width: i32,
691    height: i32,
692    kind: Kind,
693    interpolate: bool,
694}
695
696enum Kind {
697    /// 1 bpp (`k1bppRgb`), with the two-entry palette pdfium builds for a
698    /// non-default `/Decode` or an Indexed space (`None` = the stock
699    /// black/white, no palette) — applied through the 256-step ramp after
700    /// the stretch.
701    Bilevel {
702        data: Vec<u8>,
703        stride: usize,
704        palette: Option<[[u8; 3]; 2]>,
705    },
706    /// An `/ImageMask` stencil (`k1bppMask`): bit 1 = paint with the fill
707    /// colour, stretched to 8-bit coverage.
708    Mask { data: Vec<u8>, stride: usize },
709    /// 8-bit gray, no palette (`k8bppRgb`).
710    Gray8(Vec<u8>),
711    /// 8-bit RGB (`kBgr`), or a paletted image already looked up.
712    Rgb8(Vec<u8>),
713}
714
715/// The image colour spaces this module reproduces.
716#[derive(Clone)]
717enum Cs {
718    Gray,
719    Rgb {
720        /// A CalRGB carrying `/Gamma` or `/Matrix`: pdfium copies image
721        /// samples through untouched but converts a *fill colour* through
722        /// XYZ, so it is fine for images and declined for `sc`.
723        cal_rgb_with_transform: bool,
724    },
725    Indexed {
726        base: Base,
727        hival: i64,
728        table: Vec<u8>,
729    },
730}
731
732#[derive(Clone, Copy, PartialEq, Eq)]
733enum Base {
734    Gray,
735    Rgb,
736}
737
738/// Resolve a `/ColorSpace` object (`CPDF_DocPageData::GetColorSpace`).
739fn color_space(
740    doc: &Document,
741    cs: &Object,
742    res: Option<&Dictionary>,
743    page_res: Option<&Dictionary>,
744) -> Result<Cs, String> {
745    match deref(doc, cs) {
746        Object::Name(n) => match n.as_slice() {
747            b"DeviceGray" | b"G" | b"CalGray" => Ok(Cs::Gray),
748            b"DeviceRGB" | b"RGB" | b"CalRGB" => Ok(Cs::Rgb {
749                cal_rgb_with_transform: false,
750            }),
751            b"DeviceCMYK" | b"CMYK" | b"Pattern" | b"I" | b"Indexed" => {
752                Err(format!("colour space /{}", String::from_utf8_lossy(n)))
753            }
754            other => {
755                // A named resource.
756                for r in [res, page_res].into_iter().flatten() {
757                    if let Some(o) = r
758                        .get(b"ColorSpace")
759                        .ok()
760                        .and_then(|o| as_dict(doc, o))
761                        .and_then(|d| d.get(other).ok())
762                    {
763                        return color_space(doc, o, None, None);
764                    }
765                }
766                Err(format!("colour space /{}", String::from_utf8_lossy(other)))
767            }
768        },
769        Object::Array(a) => {
770            let fam = a.first().map(|o| deref(doc, o));
771            match fam {
772                Some(Object::Name(n)) if n == b"CalGray" => Ok(Cs::Gray),
773                Some(Object::Name(n)) if n == b"CalRGB" => {
774                    let d = a.get(1).and_then(|o| as_dict(doc, o));
775                    Ok(Cs::Rgb {
776                        cal_rgb_with_transform: d
777                            .is_some_and(|d| d.get(b"Gamma").is_ok() || d.get(b"Matrix").is_ok()),
778                    })
779                }
780                Some(Object::Name(n)) if n == b"ICCBased" => {
781                    let stream = a
782                        .get(1)
783                        .map(|o| deref(doc, o))
784                        .and_then(|o| o.as_stream().ok())
785                        .ok_or("ICCBased stream")?;
786                    let n = stream
787                        .dict
788                        .get(b"N")
789                        .ok()
790                        .and_then(|o| deref(doc, o).as_i64().ok())
791                        .unwrap_or(0);
792                    // pdfium treats exactly one profile as a plain swap — the
793                    // 3144-byte sRGB IEC61966-2.1 — and runs Little-CMS on
794                    // every other one.
795                    let bytes = stream.decompressed_content().unwrap_or_default();
796                    if n == 3
797                        && bytes.len() == 3144
798                        && bytes.get(400..417) == Some(b"sRGB IEC61966-2.1")
799                    {
800                        Ok(Cs::Rgb {
801                            cal_rgb_with_transform: false,
802                        })
803                    } else {
804                        Err("ICC profile (Little-CMS transform)".into())
805                    }
806                }
807                Some(Object::Name(n)) if n == b"Indexed" || n == b"I" => {
808                    if a.len() < 4 {
809                        return Err("Indexed array".into());
810                    }
811                    let base = match color_space(doc, &a[1], res, page_res)? {
812                        Cs::Gray => Base::Gray,
813                        Cs::Rgb { .. } => Base::Rgb,
814                        Cs::Indexed { .. } => return Err("Indexed over Indexed".into()),
815                    };
816                    // pdfium clamps hival to 0..=255 so out-of-range files load.
817                    let hival = deref(doc, &a[2]).as_i64().unwrap_or(0).clamp(0, 255);
818                    let table = match deref(doc, &a[3]) {
819                        Object::String(s, _) => s.clone(),
820                        Object::Stream(s) => s
821                            .decompressed_content()
822                            .map_err(|e| format!("Indexed lookup: {e}"))?,
823                        _ => return Err("Indexed lookup".into()),
824                    };
825                    Ok(Cs::Indexed { base, hival, table })
826                }
827                Some(Object::Name(n)) => {
828                    Err(format!("colour space /{}", String::from_utf8_lossy(n)))
829                }
830                _ => Err("colour space".into()),
831            }
832        }
833        _ => Err("colour space".into()),
834    }
835}
836
837impl Cs {
838    /// `CPDF_ColorSpace::GetRGBOrZerosOnError` for one component value
839    /// (`GetRGB`, then `FXSYS_roundf(· 255)` as `LoadPalette` does).
840    fn rgb_of(&self, value: f32) -> [u8; 3] {
841        match self {
842            Cs::Gray | Cs::Rgb { .. } => {
843                let g = to_byte(value);
844                [g, g, g]
845            }
846            Cs::Indexed { base, hival, table } => {
847                // `CPDF_IndexedCS::GetRGB`: truncate, range-check against
848                // hival and the table, base component = byte / 255.
849                let index = value as i32;
850                let n = if *base == Base::Gray { 1 } else { 3 };
851                if index < 0 || i64::from(index) > *hival {
852                    return [0, 0, 0];
853                }
854                let start = index as usize * n;
855                if start + n > table.len() {
856                    return [0, 0, 0];
857                }
858                let comp = |i: usize| f32::from(table[start + i]) / 255.0;
859                match base {
860                    Base::Gray => {
861                        let g = to_byte(comp(0));
862                        [g, g, g]
863                    }
864                    Base::Rgb => [to_byte(comp(0)), to_byte(comp(1)), to_byte(comp(2))],
865                }
866            }
867        }
868    }
869}
870
871/// `CPDF_DIB::LoadPalette` for a one-component image of `bits` bits per
872/// pixel: the palette pdfium attaches (`None` = it keeps none and the
873/// samples are gray values as they are), from the `/Decode` range and the
874/// colour space.
875fn palette(cs: &Cs, bits: u32, decode: Option<&[f32]>) -> Result<Option<Vec<[u8; 3]>>, String> {
876    let max_data = ((1u32 << bits) - 1) as f32;
877    // `GetDecodeAndMaskArray`: default range 0..1, or 0..max_data for
878    // Indexed; a `/Decode` sets min and step and flags a non-default one.
879    let (def_min, def_max) = match cs {
880        Cs::Indexed { .. } => (0.0f32, max_data),
881        _ => (0.0, 1.0),
882    };
883    let (dmin, step, default_decode) = match decode {
884        Some(d) if d.len() >= 2 => {
885            let (min, max) = (d[0], d[1]);
886            (
887                min,
888                (max - min) / max_data,
889                def_min == min && def_max == max,
890            )
891        }
892        Some(_) => return Err("Decode array".into()),
893        None => (def_min, (def_max - def_min) / max_data, true),
894    };
895    let stock_gray = matches!(cs, Cs::Gray);
896    if bits == 1 {
897        if default_decode && stock_gray {
898            return Ok(None);
899        }
900        let c0 = cs.rgb_of(dmin);
901        let c1 = match cs {
902            Cs::Indexed { hival: 0, .. } => [0, 0, 0],
903            _ => cs.rgb_of(dmin + step),
904        };
905        if c0 == [0, 0, 0] && c1 == [255, 255, 255] {
906            return Ok(None);
907        }
908        return Ok(Some(vec![c0, c1]));
909    }
910    if bits == 8 && default_decode && stock_gray {
911        return Ok(None);
912    }
913    Ok(Some(
914        (0..(1u32 << bits))
915            .map(|i| cs.rgb_of(dmin + step * i as f32))
916            .collect(),
917    ))
918}
919
920/// `GetBits8`: the `bpc`-bit sample at `index` of a byte-aligned row.
921fn sample(row: &[u8], index: usize, bpc: u32) -> u8 {
922    match bpc {
923        8 => row[index],
924        _ => {
925            let bitpos = index * bpc as usize;
926            (row[bitpos / 8] >> (8 - bpc as usize - bitpos % 8)) & ((1u8 << bpc) - 1)
927        }
928    }
929}
930
931/// The sample rows a filter chain yields.
932enum Rows {
933    /// Byte-aligned packed rows of `bpc`-bit samples.
934    Packed { data: Vec<u8>, bpc: u32 },
935    /// CCITT: pdfium's 32-bit-pitch 1-bit rows, `None` = a zero row.
936    Fax(Vec<Option<Vec<u8>>>),
937    /// A decoded JPEG (possibly at a reduced scale).
938    Jpeg(jpeg::Image),
939}
940
941/// Decode one image XObject the way `CPDF_DIB` loads it, or say why not.
942/// `device` is the render bitmap size — pdfium decodes a JPEG at a reduced
943/// DCT scale when the image is at least twice as large.
944fn decode_image(
945    ctx: &Ctx<'_>,
946    stream: &lopdf::Stream,
947    device: (u32, u32),
948) -> Result<Decoded, String> {
949    let doc = ctx.doc;
950    let d = &stream.dict;
951    let int = |k: &[u8]| d.get(k).ok().and_then(|o| deref(doc, o).as_i64().ok());
952    let width = int(b"Width").or_else(|| int(b"W")).ok_or("Width")?;
953    let height = int(b"Height").or_else(|| int(b"H")).ok_or("Height")?;
954    if width <= 0 || height <= 0 || width > 1 << 16 || height > 1 << 16 {
955        return Err("image size".into());
956    }
957    if d.get(b"SMask").is_ok() || d.get(b"Mask").is_ok() {
958        return Err("soft/colour-key mask".into());
959    }
960    let is_mask = bool_or_int_true(doc, d.get(b"ImageMask").ok().or_else(|| d.get(b"IM").ok()));
961    let interpolate =
962        bool_or_int_true(doc, d.get(b"Interpolate").ok().or_else(|| d.get(b"I").ok()));
963    let decode: Option<Vec<f32>> = match d
964        .get(b"Decode")
965        .ok()
966        .or_else(|| d.get(b"D").ok())
967        .map(|o| deref(doc, o))
968    {
969        Some(Object::Array(a)) => Some(a.iter().filter_map(|o| num(deref(doc, o))).collect()),
970        _ => None,
971    };
972    let (w, h) = (width as usize, height as usize);
973
974    let chain = filters::filters(doc, d);
975    let (data, codec) =
976        filters::apply(doc, &stream.content, &chain).map_err(|e| format!("filter {e:?}"))?;
977
978    let rows = match codec {
979        Some(codec) if codec.name == "DCTDecode" => {
980            if is_mask {
981                return Err("DCT image mask".into());
982            }
983            // pdfium's tip (2025+) asks libjpeg for a `1/2^n` DCT-scaled decode
984            // when the image is at least twice the bitmap in both dimensions
985            // (`CPDF_DIB::StartLoadDIBBase`, `log2(min(w/W, h/H))` capped at
986            // 3); the pinned conformance build decodes at full size and
987            // stretches — the oracle test says so, byte for byte — and this
988            // raster follows the pinned build. `jpeg::decode` implements the
989            // reduced sizes (`jidctred`), so flipping this constant is all a
990            // move of the reference needs.
991            const DCT_SCALING_LIKE_PDFIUM_TIP: bool = false;
992            let (dw, dh) = (i64::from(device.0), i64::from(device.1));
993            let mut levels = 0u32;
994            if DCT_SCALING_LIKE_PDFIUM_TIP && dw > 0 && dh > 0 {
995                let ratio = (width / dw).min(height / dh).max(1);
996                levels = (63 - ratio.leading_zeros()).min(3);
997            }
998            let transform = codec
999                .parms
1000                .as_ref()
1001                .and_then(|p| p.get(b"ColorTransform").ok())
1002                .and_then(|o| deref(doc, o).as_i64().ok())
1003                .unwrap_or(1)
1004                != 0;
1005            let img =
1006                jpeg::decode(&data, transform, 1 << levels).map_err(|e| format!("JPEG {e:?}"))?;
1007            if img.width != w.div_ceil(1 << levels) || img.height != h.div_ceil(1 << levels) {
1008                return Err("JPEG size differs from the dictionary".into());
1009            }
1010            Rows::Jpeg(img)
1011        }
1012        Some(codec) if codec.name == "CCITTFaxDecode" => {
1013            let p = codec.parms.as_ref();
1014            let pi = |k: &[u8], default: i64| {
1015                p.and_then(|p| p.get(k).ok())
1016                    .and_then(|o| deref(doc, o).as_i64().ok())
1017                    .unwrap_or(default)
1018            };
1019            let pb = |k: &[u8]| p.is_some_and(|p| bool_or_int_true(doc, p.get(k).ok()));
1020            let mut rows_param = pi(b"Rows", 0);
1021            if rows_param > i64::from(u16::MAX) {
1022                rows_param = 0;
1023            }
1024            let params = fax::Params {
1025                k: pi(b"K", 0) as i32,
1026                end_of_line: pb(b"EndOfLine"),
1027                byte_align: pb(b"EncodedByteAlign"),
1028                black_is_1: pb(b"BlackIs1"),
1029                columns: pi(b"Columns", 1728).max(0) as usize,
1030                rows: rows_param.max(0) as usize,
1031            };
1032            if params.columns == 0 || params.columns > 65535 {
1033                return Err("CCITT columns".into());
1034            }
1035            // `CreateDecoder`: the decoder's rows must be at least as wide
1036            // as the image's.
1037            if fax::pitch(params.columns) < w.div_ceil(8) {
1038                return Err("CCITT columns narrower than the image".into());
1039            }
1040            Rows::Fax(fax::decode(&data, &params, h))
1041        }
1042        Some(codec) => return Err(format!("codec {}", codec.name)),
1043        None => {
1044            let bpc = if is_mask {
1045                1
1046            } else {
1047                int(b"BitsPerComponent")
1048                    .or_else(|| int(b"BPC"))
1049                    .unwrap_or(0)
1050            };
1051            let bpc = match bpc {
1052                1 | 2 | 4 | 8 => bpc as u32,
1053                other => return Err(format!("{other} bits per component")),
1054            };
1055            Rows::Packed { data, bpc }
1056        }
1057    };
1058
1059    if is_mask {
1060        // `k1bppMask`: `default_decode_ = !Decode || Decode[0] == 0` (as an
1061        // integer); a default-decode row is inverted so that 1 = paint.
1062        let default_decode = decode
1063            .as_ref()
1064            .is_none_or(|d| d.first().is_none_or(|v| *v as i64 == 0));
1065        let stride = w.div_ceil(8);
1066        let mut out = vec![0u8; stride * h];
1067        match rows {
1068            Rows::Packed { data, .. } => {
1069                if data.len() < stride * h {
1070                    return Err("truncated image data".into());
1071                }
1072                for (y, row) in out.chunks_exact_mut(stride).enumerate() {
1073                    for (o, &s) in row.iter_mut().zip(&data[y * stride..y * stride + stride]) {
1074                        *o = if default_decode { !s } else { s };
1075                    }
1076                }
1077            }
1078            Rows::Fax(lines) => {
1079                for (y, row) in out.chunks_exact_mut(stride).enumerate() {
1080                    if let Some(Some(line)) = lines.get(y) {
1081                        for (o, &s) in row.iter_mut().zip(&line[..stride]) {
1082                            *o = if default_decode { !s } else { s };
1083                        }
1084                    }
1085                }
1086            }
1087            Rows::Jpeg(_) => return Err("DCT image mask".into()),
1088        }
1089        return Ok(Decoded {
1090            width: width as i32,
1091            height: height as i32,
1092            kind: Kind::Mask { data: out, stride },
1093            interpolate,
1094        });
1095    }
1096
1097    let cs_obj = d
1098        .get(b"ColorSpace")
1099        .ok()
1100        .or_else(|| d.get(b"CS").ok())
1101        .ok_or("no ColorSpace")?;
1102    let cs = color_space(doc, cs_obj, ctx.res, ctx.page_res)?;
1103    let comps = match cs {
1104        Cs::Rgb { .. } => 3,
1105        _ => 1,
1106    };
1107    let default_rgb = decode
1108        .as_deref()
1109        .is_none_or(|dec| dec == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0]);
1110
1111    let (out_w, out_h, kind) = match rows {
1112        Rows::Jpeg(img) => {
1113            let kind = match (img.channels, comps) {
1114                // 8-bit gray, with a palette when the space or Decode is not
1115                // the stock one.
1116                (1, 1) => match palette(&cs, 8, decode.as_deref())? {
1117                    None => Kind::Gray8(img.data),
1118                    Some(pal) => {
1119                        Kind::Rgb8(img.data.iter().flat_map(|&v| pal[usize::from(v)]).collect())
1120                    }
1121                },
1122                (3, 3) => rgb8(img.data, decode.as_deref(), default_rgb),
1123                _ => return Err("JPEG components vs colour space".into()),
1124            };
1125            (img.width as i32, img.height as i32, kind)
1126        }
1127        Rows::Fax(lines) => {
1128            // 1-bit gray: pdfium's rows (1 = white), zero rows where the data
1129            // ran out, through the 1-bit palette rules.
1130            if comps != 1 {
1131                return Err("CCITT with an RGB colour space".into());
1132            }
1133            let stride = w.div_ceil(8);
1134            let mut out = vec![0u8; stride * h];
1135            for (y, row) in out.chunks_exact_mut(stride).enumerate() {
1136                if let Some(Some(line)) = lines.get(y) {
1137                    row.copy_from_slice(&line[..stride]);
1138                }
1139            }
1140            let pal = palette(&cs, 1, decode.as_deref())?.map(|p| [p[0], p[1]]);
1141            (
1142                width as i32,
1143                height as i32,
1144                Kind::Bilevel {
1145                    data: out,
1146                    stride,
1147                    palette: pal,
1148                },
1149            )
1150        }
1151        Rows::Packed { data, bpc } => {
1152            let stride = (w * bpc as usize * comps).div_ceil(8);
1153            if data.len() < stride * h {
1154                return Err("truncated image data".into());
1155            }
1156            let kind = if comps == 3 {
1157                if bpc != 8 {
1158                    return Err(format!("{bpc}-bit RGB"));
1159                }
1160                rgb8(data, decode.as_deref(), default_rgb)
1161            } else if bpc == 1 {
1162                let pal = palette(&cs, 1, decode.as_deref())?.map(|p| [p[0], p[1]]);
1163                Kind::Bilevel {
1164                    data,
1165                    stride,
1166                    palette: pal,
1167                }
1168            } else {
1169                // 2/4/8-bit one-component: `TranslateScanline` unpacks the
1170                // indices, the palette (if any) turns them into colours.
1171                match palette(&cs, bpc, decode.as_deref())? {
1172                    None => Kind::Gray8(data),
1173                    Some(pal) => {
1174                        let mut out = Vec::with_capacity(w * h * 3);
1175                        for y in 0..h {
1176                            let row = &data[y * stride..y * stride + stride];
1177                            for x in 0..w {
1178                                out.extend_from_slice(&pal[usize::from(sample(row, x, bpc))]);
1179                            }
1180                        }
1181                        Kind::Rgb8(out)
1182                    }
1183                }
1184            };
1185            (width as i32, height as i32, kind)
1186        }
1187    };
1188    Ok(Decoded {
1189        width: out_w,
1190        height: out_h,
1191        kind,
1192        interpolate,
1193    })
1194}
1195
1196/// 8-bit RGB samples: a default `/Decode` passes through; any other range
1197/// goes through `TranslateScanline24bpp` — `min + step · v` per component,
1198/// clamped, `· 255` truncated to a byte.
1199fn rgb8(data: Vec<u8>, decode: Option<&[f32]>, default_rgb: bool) -> Kind {
1200    if default_rgb {
1201        return Kind::Rgb8(data);
1202    }
1203    let dec = decode.unwrap_or(&[0.0, 1.0, 0.0, 1.0, 0.0, 1.0]);
1204    let tables: Vec<[u8; 256]> = (0..3)
1205        .map(|c| {
1206            let (min, max) = (
1207                dec.get(2 * c).copied().unwrap_or(0.0),
1208                dec.get(2 * c + 1).copied().unwrap_or(1.0),
1209            );
1210            let step = (max - min) / 255.0;
1211            let mut t = [0u8; 256];
1212            for (v, slot) in t.iter_mut().enumerate() {
1213                *slot = ((min + step * v as f32).clamp(0.0, 1.0) * 255.0) as u8;
1214            }
1215            t
1216        })
1217        .collect();
1218    let mut out = data;
1219    for px in out.chunks_exact_mut(3) {
1220        for (c, v) in px.iter_mut().enumerate() {
1221            *v = tables[c][usize::from(*v)];
1222        }
1223    }
1224    Kind::Rgb8(out)
1225}
1226
1227/// How a stretched sample lands on the canvas.
1228enum Blend {
1229    Gray,
1230    Rgb,
1231    /// `BuildPaletteFrom1BppSource`: the 256-step ramp between the two
1232    /// palette colours, integer arithmetic.
1233    Ramp([[u8; 3]; 2]),
1234    /// `CompositeRow_ByteMask2Rgb` with the fill colour: coverage `a` blends
1235    /// `(dest·(255−a) + fill·a) / 255`, zero coverage leaves the pixel.
1236    Mask([u8; 3]),
1237}
1238
1239fn put(canvas: &mut RgbImage, x: usize, y: usize, px: &[u8], blend: &Blend) {
1240    if x >= canvas.width() as usize || y >= canvas.height() as usize {
1241        return;
1242    }
1243    let p = canvas.get_pixel_mut(x as u32, y as u32);
1244    match blend {
1245        Blend::Gray => *p = image::Rgb([px[0], px[0], px[0]]),
1246        Blend::Rgb => *p = image::Rgb([px[0], px[1], px[2]]),
1247        Blend::Ramp([p0, p1]) => {
1248            let v = i32::from(px[0]);
1249            let ramp = |c: usize| {
1250                (i32::from(p0[c]) + (i32::from(p1[c]) - i32::from(p0[c])) * v / 255) as u8
1251            };
1252            *p = image::Rgb([ramp(0), ramp(1), ramp(2)]);
1253        }
1254        Blend::Mask(fill) => {
1255            let a = i32::from(px[0]);
1256            if a == 0 {
1257                return;
1258            }
1259            for (d, &f) in p.0.iter_mut().zip(fill) {
1260                *d = ((i32::from(*d) * (255 - a) + i32::from(f) * a) / 255) as u8;
1261            }
1262        }
1263    }
1264}
1265
1266/// `CFX_AggImageRenderer` for one placement: the image rectangle, the clip,
1267/// the axis-aligned or 90° stretch, and the composite onto `canvas`.
1268fn draw(
1269    canvas: &mut RgbImage,
1270    img: &Decoded,
1271    m: M,
1272    clip: Rect,
1273    fill: Option<[u8; 3]>,
1274) -> Result<(), String> {
1275    let unit = [
1276        m.apply(0.0, 1.0),
1277        m.apply(0.0, 0.0),
1278        m.apply(1.0, 1.0),
1279        m.apply(1.0, 0.0),
1280    ];
1281    let image_rect = FRect::from_points(&unit).outer();
1282    let mut clip_box = clip;
1283    clip_box.intersect(&image_rect);
1284    if clip_box.is_empty() {
1285        return Ok(());
1286    }
1287    // `StartDIBBase`: a huge image (> 60 MB of samples) is always bilinear.
1288    let bytes = i64::from(img.width)
1289        * i64::from(img.height)
1290        * match img.kind {
1291            Kind::Bilevel { .. } | Kind::Mask { .. } => 0,
1292            Kind::Gray8(_) => 1,
1293            Kind::Rgb8(_) => 3,
1294        };
1295    let options = Options {
1296        bilinear: img.interpolate || bytes > 60_000_000,
1297        no_smoothing: false,
1298    };
1299    let (source, blend) = match &img.kind {
1300        Kind::Bilevel {
1301            data,
1302            stride,
1303            palette,
1304        } => (
1305            Source::Bilevel {
1306                data,
1307                stride: *stride,
1308            },
1309            match palette {
1310                Some(p) => Blend::Ramp(*p),
1311                None => Blend::Gray,
1312            },
1313        ),
1314        Kind::Mask { data, stride } => {
1315            // A fill colour this module could not convert declines the page.
1316            let fill = fill.ok_or("stencil mask fill colour")?;
1317            (
1318                Source::Bilevel {
1319                    data,
1320                    stride: *stride,
1321                },
1322                Blend::Mask(fill),
1323            )
1324        }
1325        Kind::Gray8(v) => (
1326            Source::Gray8 {
1327                data: v,
1328                stride: img.width as usize,
1329            },
1330            Blend::Gray,
1331        ),
1332        Kind::Rgb8(v) => (
1333            Source::Rgb8 {
1334                data: v,
1335                stride: 3 * img.width as usize,
1336            },
1337            Blend::Rgb,
1338        ),
1339    };
1340    let rotated = (m.b.abs() >= 0.5 || m.a == 0.0) || (m.c.abs() >= 0.5 || m.d == 0.0);
1341    if rotated {
1342        if !(m.a.abs() < m.b.abs() / 20.0
1343            && m.d.abs() < m.c.abs() / 20.0
1344            && m.a.abs() < 0.5
1345            && m.d.abs() < 0.5)
1346        {
1347            return Err("general affine image placement".into());
1348        }
1349        let dest_width = image_rect.width();
1350        let dest_height = image_rect.height();
1351        let mut bitmap_clip = clip_box;
1352        bitmap_clip.offset(-image_rect.left, -image_rect.top);
1353        let flip_x = m.c > 0.0;
1354        let flip_y = m.b < 0.0;
1355        let bitmap_clip = bitmap_clip.swapped_clip_box(dest_width, dest_height, flip_x, flip_y);
1356        let s = stretch::stretch(
1357            &source,
1358            img.width,
1359            img.height,
1360            dest_height,
1361            dest_width,
1362            bitmap_clip,
1363            options,
1364        )
1365        .ok_or("stretch")?;
1366        // `ComposeScanlineV`: stretched row `line` → device column.
1367        let (cw, ch) = (clip_box.width() as usize, clip_box.height() as usize);
1368        if s.height != cw || s.width != ch {
1369            return Err("swapped clip size".into());
1370        }
1371        for line in 0..cw {
1372            let dx = clip_box.left as usize + if flip_x { cw - line - 1 } else { line };
1373            for i in 0..ch {
1374                let dy = if flip_y {
1375                    clip_box.top as usize + ch - 1 - i
1376                } else {
1377                    clip_box.top as usize + i
1378                };
1379                put(
1380                    canvas,
1381                    dx,
1382                    dy,
1383                    &s.data[(line * s.width + i) * s.channels..][..s.channels],
1384                    &blend,
1385                );
1386            }
1387        }
1388        return Ok(());
1389    }
1390    let mut dest_width = image_rect.width();
1391    if m.a < 0.0 {
1392        dest_width = -dest_width;
1393    }
1394    let mut dest_height = image_rect.height();
1395    if m.d > 0.0 {
1396        dest_height = -dest_height;
1397    }
1398    if dest_width == 0 || dest_height == 0 {
1399        return Ok(());
1400    }
1401    let mut bitmap_clip = clip_box;
1402    bitmap_clip.offset(-image_rect.left, -image_rect.top);
1403    let s = stretch::stretch(
1404        &source,
1405        img.width,
1406        img.height,
1407        dest_width,
1408        dest_height,
1409        bitmap_clip,
1410        options,
1411    )
1412    .ok_or("stretch")?;
1413    for row in 0..s.height {
1414        for col in 0..s.width {
1415            put(
1416                canvas,
1417                clip_box.left as usize + col,
1418                clip_box.top as usize + row,
1419                &s.data[(row * s.width + col) * s.channels..][..s.channels],
1420                &blend,
1421            );
1422        }
1423    }
1424    Ok(())
1425}
1426
1427fn render_inner(meta: &PdfMeta, index: usize, width: u32, height: u32) -> Result<RgbImage, String> {
1428    if !enabled() {
1429        return Err("DOCLING_RS_SCAN_RASTER=pdfium".into());
1430    }
1431    if width == 0 || height == 0 || width > 1 << 15 || height > 1 << 15 {
1432        return Err("bitmap size".into());
1433    }
1434    let doc = meta.doc();
1435    let pid = meta.page_id(index).ok_or("page index")?;
1436    let page = doc
1437        .get_object(pid)
1438        .ok()
1439        .and_then(|o| o.as_dict().ok())
1440        .ok_or("page dict")?;
1441    // Annotations: `FPDF_ANNOT` draws appearance streams and pdfium generates
1442    // some (Square, Text, …) itself; links draw nothing. Widgets are drawn by
1443    // the form-fill layer pdfium-render also runs.
1444    if let Some(annots) = page
1445        .get(b"Annots")
1446        .ok()
1447        .map(|o| deref(doc, o))
1448        .and_then(|o| o.as_array().ok())
1449    {
1450        for a in annots {
1451            let Some(ad) = as_dict(doc, a) else { continue };
1452            let hidden = ad
1453                .get(b"F")
1454                .ok()
1455                .and_then(|o| deref(doc, o).as_i64().ok())
1456                .is_some_and(|f| f & 2 != 0);
1457            if hidden {
1458                continue;
1459            }
1460            if !name_is(ad.get(b"Subtype").ok().map(|o| deref(doc, o)), b"Link") {
1461                return Err("annotation".into());
1462            }
1463        }
1464    }
1465
1466    // pdfium's page geometry: the display box and `/Rotate` fold into
1467    // `page_matrix_`, the display matrix maps the (rotated) page size onto
1468    // the bitmap with y flipped (`CPDF_Page::GetDisplayMatrixForFloatRect`).
1469    let pb = crate::textparse::page_box(doc, pid);
1470    let geom = meta.geometry(index).ok_or("geometry")?;
1471    let (l, b, r, t) = (pb.l, pb.b, pb.l + pb.w, pb.b + pb.h);
1472    let page_matrix = match geom.rotation {
1473        90 => M {
1474            a: 0.0,
1475            b: -1.0,
1476            c: 1.0,
1477            d: 0.0,
1478            e: -b,
1479            f: r,
1480        },
1481        180 => M {
1482            a: -1.0,
1483            b: 0.0,
1484            c: 0.0,
1485            d: -1.0,
1486            e: r,
1487            f: t,
1488        },
1489        270 => M {
1490            a: 0.0,
1491            b: 1.0,
1492            c: -1.0,
1493            d: 0.0,
1494            e: t,
1495            f: -l,
1496        },
1497        _ => M {
1498            a: 1.0,
1499            b: 0.0,
1500            c: 0.0,
1501            d: 1.0,
1502            e: -l,
1503            f: -b,
1504        },
1505    };
1506    let (pw, ph) = (geom.width, geom.height);
1507    if pw == 0.0 || ph == 0.0 {
1508        return Err("page size".into());
1509    }
1510    let (wf, hf) = (width as f32, height as f32);
1511    // rotation 0: x0 = 0, y0 = H (FloatRect.top), x1 = 0, y1 = 0, x2 = W, y2 = H.
1512    let display = M {
1513        a: wf / pw,
1514        b: 0.0 / pw,
1515        c: 0.0 / ph,
1516        d: (0.0 - hf) / ph,
1517        e: 0.0,
1518        f: hf,
1519    };
1520    let obj2dev = page_matrix.then(display);
1521    let device = Rect::new(0, 0, width as i32, height as i32);
1522
1523    let page_res = doc.get_page_resources(pid).ok().and_then(|(inline, ids)| {
1524        inline.or_else(|| ids.into_iter().find_map(|id| doc.get_dictionary(id).ok()))
1525    });
1526    let ctx = Ctx {
1527        doc,
1528        obj2dev,
1529        res: page_res,
1530        page_res,
1531        device,
1532        depth: 0,
1533    };
1534    let content = doc.get_page_content(pid);
1535    let mut walker = Walker {
1536        draws: Vec::new(),
1537        ops: 0,
1538    };
1539    walker.walk(
1540        &ctx,
1541        &content,
1542        GState {
1543            ctm: M::ID,
1544            clip: device,
1545            text_render: 0,
1546            fill: Some([0, 0, 0]),
1547            fill_cs: FillCs::Gray,
1548        },
1549    )?;
1550    if walker.draws.is_empty() {
1551        return Err("no image on the page".into());
1552    }
1553
1554    let mut canvas = RgbImage::from_pixel(width, height, image::Rgb([255, 255, 255]));
1555    let mut cache: HashMap<ObjectId, Decoded> = HashMap::new();
1556    for dr in &walker.draws {
1557        if let std::collections::hash_map::Entry::Vacant(e) = cache.entry(dr.stream) {
1558            let stream = doc
1559                .get_object(dr.stream)
1560                .ok()
1561                .and_then(|o| o.as_stream().ok())
1562                .ok_or("image stream")?;
1563            let decoded = crate::timing::timed("raster.decode", || {
1564                decode_image(&ctx, stream, (width, height))
1565            })?;
1566            e.insert(decoded);
1567        }
1568        let img = &cache[&dr.stream];
1569        crate::timing::timed("raster.stretch", || {
1570            draw(&mut canvas, img, dr.matrix, dr.clip, dr.fill)
1571        })?;
1572    }
1573    Ok(canvas)
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578    use super::*;
1579    use lopdf::{dictionary, Stream};
1580
1581    fn root() -> std::path::PathBuf {
1582        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
1583    }
1584
1585    fn fixture(rel: &str) -> Vec<u8> {
1586        std::fs::read(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
1587    }
1588
1589    fn crate_fixture(rel: &str) -> Vec<u8> {
1590        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel);
1591        std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display()))
1592    }
1593
1594    /// The scanned fixtures this module must take over from pdfium.
1595    const IMAGE_ONLY: &[&str] = &[
1596        "tests/data/scanned/sources/ocr_test.pdf",
1597        "tests/data/scanned/sources/ocr_test_rotated_90.pdf",
1598        "tests/data/scanned/sources/ocr_test_rotated_180.pdf",
1599        "tests/data/scanned/sources/ocr_test_rotated_270.pdf",
1600        "tests/data/scanned/sources/ocr_test_raster.pdf",
1601        "tests/data/scanned/sources/ocr_test_raster_rot_90.pdf",
1602        "tests/data/scanned/sources/ocr_test_raster_rot_180.pdf",
1603        "tests/data/scanned/sources/ocr_test_raster_rot_270.pdf",
1604        "tests/data/scanned/sources/nemotron_multipage.pdf",
1605        "tests/data/scanned/sources/scanned_chart_table.pdf",
1606        "tests/data/pdf/sources/docling-rs-demotion-repro.pdf",
1607    ];
1608
1609    /// The two render sizes the pipeline asks for: the OCR/TableFormer bitmap
1610    /// (1.5 × the 2.0 render scale, rounded) and the layout image (1.5 ×,
1611    /// ceiled) — the same arithmetic as `pdfium_backend::extract_page`.
1612    fn sizes(w: f32, h: f32) -> [(u32, u32); 2] {
1613        [
1614            (
1615                (w * 3.0).round().max(1.0) as u32,
1616                (h * 3.0).round().max(1.0) as u32,
1617            ),
1618            (
1619                (w * 1.5).ceil().max(1.0) as u32,
1620                (h * 1.5).ceil().max(1.0) as u32,
1621            ),
1622        ]
1623    }
1624
1625    #[test]
1626    fn born_digital_pages_are_declined() {
1627        let meta = PdfMeta::open(&fixture("tests/data/pdf/sources/2206.01062.pdf")).unwrap();
1628        assert!(render(&meta, 0, 612, 792).is_none());
1629        // The ICC-profiled scan (Little-CMS in pdfium).
1630        let meta = PdfMeta::open(&fixture(
1631            "tests/data/scanned/sources/sample_with_rotation_mismatch.pdf",
1632        ))
1633        .unwrap();
1634        assert!(render(&meta, 0, 842, 595).is_none());
1635    }
1636
1637    #[test]
1638    fn image_only_pages_render() {
1639        for rel in IMAGE_ONLY {
1640            let meta = PdfMeta::open(&fixture(rel)).unwrap();
1641            let g = meta.geometry(0).unwrap();
1642            let (w, h) = sizes(g.width, g.height)[1];
1643            let img = render(&meta, 0, w, h).unwrap_or_else(|| panic!("{rel} declined"));
1644            assert_eq!((img.width(), img.height()), (w, h), "{rel}");
1645            // A scan is mostly paper: white dominates, but not everything is.
1646            let dark = img.pixels().filter(|p| p[0] < 128).count();
1647            assert!(
1648                dark > 0 && dark < (w * h / 2) as usize,
1649                "{rel}: {dark} dark px"
1650            );
1651        }
1652    }
1653
1654    /// Compare this module with pdfium on page `index` of `pdf` at `sizes`,
1655    /// appending a line per mismatch to `failures`; `false` when pdfium is
1656    /// not installed. The oracle needs the `pdfium` feature.
1657    #[cfg(feature = "pdfium")]
1658    fn oracle(
1659        label: &str,
1660        pdf: &[u8],
1661        index: usize,
1662        sizes: &[(u32, u32)],
1663        failures: &mut Vec<String>,
1664    ) -> bool {
1665        use pdfium_render::prelude::*;
1666        let pdfium = match crate::pdfium_backend::bind_for_tests() {
1667            Ok(p) => p,
1668            Err(e) => {
1669                eprintln!("pdfium not installed — skipping the raster oracle ({e:?})");
1670                return false;
1671            }
1672        };
1673        let meta = PdfMeta::open(pdf).unwrap_or_else(|| panic!("{label}: lopdf cannot open"));
1674        let doc = pdfium
1675            .load_pdf_from_byte_slice(pdf, None)
1676            .unwrap_or_else(|e| panic!("{label}: pdfium {e:?}"));
1677        let page = doc.pages().get(index as PdfPageIndex).unwrap();
1678        for &(w, h) in sizes {
1679            let want = page
1680                .render_with_config(
1681                    &PdfRenderConfig::new()
1682                        .set_target_width(w as i32)
1683                        .set_target_height(h as i32),
1684                )
1685                .unwrap()
1686                .as_image()
1687                .into_rgb8();
1688            let Some(got) = render(&meta, index, w, h) else {
1689                failures.push(format!("{label} @{w}x{h}: declined"));
1690                continue;
1691            };
1692            if got.as_raw() != want.as_raw() {
1693                let diff = got
1694                    .as_raw()
1695                    .iter()
1696                    .zip(want.as_raw())
1697                    .filter(|(a, b)| a != b)
1698                    .count();
1699                let max = got
1700                    .as_raw()
1701                    .iter()
1702                    .zip(want.as_raw())
1703                    .map(|(a, b)| (i32::from(*a) - i32::from(*b)).abs())
1704                    .max()
1705                    .unwrap_or(0);
1706                failures.push(format!(
1707                    "{label} @{w}x{h}: {diff} of {} bytes differ (max |Δ| {max})",
1708                    want.len()
1709                ));
1710                if std::env::var_os("DOCLING_RS_RASTER_DUMP").is_some() {
1711                    let stem = label.replace(['/', ' '], "_");
1712                    let _ = got.save(format!("/tmp/{stem}.{w}.rust.png"));
1713                    let _ = want.save(format!("/tmp/{stem}.{w}.pdfium.png"));
1714                }
1715            }
1716        }
1717        true
1718    }
1719
1720    /// The oracle on the corpus: pdfium's own bitmap, byte for byte, on every
1721    /// image-only fixture page at both pipeline sizes. Skipped without
1722    /// `libpdfium`.
1723    #[test]
1724    #[cfg(feature = "pdfium")]
1725    fn matches_pdfium_on_the_scanned_fixtures() {
1726        let mut failures = Vec::new();
1727        let mut pages = 0;
1728        for rel in IMAGE_ONLY {
1729            let bytes = fixture(rel);
1730            let meta = PdfMeta::open(&bytes).unwrap();
1731            for i in 0..meta.page_count() {
1732                let g = meta.geometry(i).unwrap();
1733                if !oracle(rel, &bytes, i, &sizes(g.width, g.height), &mut failures) {
1734                    return;
1735                }
1736                pages += 1;
1737            }
1738        }
1739        assert!(failures.is_empty(), "{}", failures.join("\n"));
1740        assert!(pages >= IMAGE_ONLY.len(), "{pages} pages compared");
1741    }
1742
1743    /// Build a one-page PDF: `page` points wide/high (with `/Rotate`), the
1744    /// content stream, and image XObjects by name.
1745    fn synth_pdf(
1746        page: (f32, f32),
1747        rotate: i64,
1748        content: &str,
1749        images: Vec<(&str, Stream)>,
1750    ) -> Vec<u8> {
1751        let mut doc = Document::with_version("1.5");
1752        let pages_id = doc.new_object_id();
1753        let mut xobjs = Dictionary::new();
1754        for (name, stream) in images {
1755            let id = doc.add_object(Object::Stream(stream));
1756            xobjs.set(name, Object::Reference(id));
1757        }
1758        let res_id = doc.add_object(dictionary! { "XObject" => Object::Dictionary(xobjs) });
1759        let content_id = doc.add_object(Object::Stream(Stream::new(
1760            Dictionary::new(),
1761            content.as_bytes().to_vec(),
1762        )));
1763        let page_id = doc.add_object(dictionary! {
1764            "Type" => "Page",
1765            "Parent" => Object::Reference(pages_id),
1766            "MediaBox" => vec![0.into(), 0.into(), page.0.into(), page.1.into()],
1767            "Rotate" => rotate,
1768            "Contents" => Object::Reference(content_id),
1769            "Resources" => Object::Reference(res_id),
1770        });
1771        doc.objects.insert(
1772            pages_id,
1773            Object::Dictionary(dictionary! {
1774                "Type" => "Pages",
1775                "Kids" => vec![Object::Reference(page_id)],
1776                "Count" => 1,
1777            }),
1778        );
1779        let catalog_id = doc.add_object(dictionary! {
1780            "Type" => "Catalog",
1781            "Pages" => Object::Reference(pages_id),
1782        });
1783        doc.trailer.set("Root", Object::Reference(catalog_id));
1784        let mut out = Vec::new();
1785        doc.save_to(&mut out).unwrap();
1786        out
1787    }
1788
1789    fn image_stream(w: usize, h: usize, extra: Dictionary, data: Vec<u8>) -> Stream {
1790        let mut d = dictionary! {
1791            "Type" => "XObject",
1792            "Subtype" => "Image",
1793            "Width" => w as i64,
1794            "Height" => h as i64,
1795        };
1796        for (k, v) in extra.into_iter() {
1797            d.set(k, v);
1798        }
1799        Stream::new(d, data)
1800    }
1801
1802    fn reals(v: &[f32]) -> Object {
1803        Object::Array(v.iter().map(|&x| Object::Real(x)).collect())
1804    }
1805
1806    /// Full-page placement of `/Im0` on a `w` × `h` point page.
1807    fn full_page(w: f32, h: f32) -> String {
1808        format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q")
1809    }
1810
1811    /// The shapes fixture as packed 1-bit rows (1 = white), byte-aligned.
1812    fn shapes_bits() -> (usize, usize, Vec<u8>) {
1813        let img = image::open(
1814            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/fax/shapes.png"),
1815        )
1816        .unwrap()
1817        .to_luma8();
1818        let (w, h) = (img.width() as usize, img.height() as usize);
1819        let stride = w.div_ceil(8);
1820        let mut out = vec![0u8; stride * h];
1821        for y in 0..h {
1822            for x in 0..w {
1823                if img.get_pixel(x as u32, y as u32)[0] >= 128 {
1824                    out[y * stride + x / 8] |= 1 << (7 - x % 8);
1825                }
1826            }
1827        }
1828        (w, h, out)
1829    }
1830
1831    /// Deterministic sample bytes.
1832    fn noise(n: usize, seed: u32) -> Vec<u8> {
1833        let mut s = seed.wrapping_mul(2654435761).wrapping_add(12345);
1834        (0..n)
1835            .map(|_| {
1836                s ^= s << 13;
1837                s ^= s >> 17;
1838                s ^= s << 5;
1839                (s >> 24) as u8
1840            })
1841            .collect()
1842    }
1843
1844    /// The synthesized pages: every image kind the module reproduces.
1845    fn synthesized_cases() -> Vec<(String, Vec<u8>, (f32, f32))> {
1846        let mut cases: Vec<(String, Vec<u8>, (f32, f32))> = Vec::new();
1847
1848        // Reduced-scale JPEG decoding: the device is small enough for
1849        // pdfium's `resolution_levels_to_skip` to be 1, 2 or 3.
1850        for name in [
1851            "rgb_420_big",
1852            "gray_444",
1853            "rgb_422",
1854            "rgb_444_progressive",
1855            "rgb_420_progressive",
1856            "gray_progressive",
1857        ] {
1858            let jpg = crate_fixture(&format!("tests/data/jpeg/{name}.jpg"));
1859            let info = jpeg::info(&jpg).unwrap();
1860            let (w, h) = (info.width as f32, info.height as f32);
1861            let cs = if info.components == 1 {
1862                "DeviceGray"
1863            } else {
1864                "DeviceRGB"
1865            };
1866            let stream = image_stream(
1867                info.width,
1868                info.height,
1869                dictionary! { "ColorSpace" => cs, "BitsPerComponent" => 8, "Filter" => "DCTDecode" },
1870                jpg,
1871            );
1872            cases.push((
1873                format!("jpeg {name}"),
1874                synth_pdf((w, h), 0, &full_page(w, h), vec![("Im0", stream)]),
1875                (w, h),
1876            ));
1877        }
1878
1879        // CCITT as 1-bit gray, both bit conventions, and as stencils.
1880        let (sw, sh, bits) = shapes_bits();
1881        let (swf, shf) = (sw as f32, sh as f32);
1882        for (name, k) in [("shapes.g4", -1i64), ("shapes.g3", 0), ("shapes.g3_2d", 4)] {
1883            let data = crate_fixture(&format!("tests/data/fax/{name}"));
1884            let parms = |black: bool| {
1885                dictionary! { "K" => k, "Columns" => sw as i64, "Rows" => sh as i64, "BlackIs1" => black }
1886            };
1887            let gray = image_stream(
1888                sw,
1889                sh,
1890                dictionary! {
1891                    "ColorSpace" => "DeviceGray", "BitsPerComponent" => 1,
1892                    "Filter" => "CCITTFaxDecode", "DecodeParms" => parms(false),
1893                },
1894                data.clone(),
1895            );
1896            cases.push((
1897                format!("ccitt {name} gray"),
1898                synth_pdf((swf, shf), 0, &full_page(swf, shf), vec![("Im0", gray)]),
1899                (swf, shf),
1900            ));
1901            let inverted = image_stream(
1902                sw,
1903                sh,
1904                dictionary! {
1905                    "ColorSpace" => "DeviceGray", "BitsPerComponent" => 1,
1906                    "Filter" => "CCITTFaxDecode", "DecodeParms" => parms(true),
1907                    "Decode" => reals(&[1.0, 0.0]),
1908                },
1909                data.clone(),
1910            );
1911            cases.push((
1912                format!("ccitt {name} blackis1+decode"),
1913                synth_pdf((swf, shf), 0, &full_page(swf, shf), vec![("Im0", inverted)]),
1914                (swf, shf),
1915            ));
1916            let mask = image_stream(
1917                sw,
1918                sh,
1919                dictionary! { "ImageMask" => true, "Filter" => "CCITTFaxDecode", "DecodeParms" => parms(false) },
1920                data,
1921            );
1922            cases.push((
1923                format!("ccitt {name} mask"),
1924                synth_pdf(
1925                    (swf, shf),
1926                    0,
1927                    &format!("0.2 0.5 0.8 rg {}", full_page(swf, shf)),
1928                    vec![("Im0", mask)],
1929                ),
1930                (swf, shf),
1931            ));
1932        }
1933
1934        // Flate stencil masks: default decode (0 = paint), `/Decode [1 0]`,
1935        // a gray fill through `cs`/`sc`, a clip, two placements, a rotated
1936        // page, `/Interpolate`.
1937        let mask_stream = |decode: Option<[f32; 2]>| {
1938            let mut d = dictionary! { "ImageMask" => true };
1939            if let Some(dec) = decode {
1940                d.set("Decode", reals(&dec));
1941            }
1942            image_stream(sw, sh, d, bits.clone())
1943        };
1944        cases.push((
1945            "mask default black".into(),
1946            synth_pdf(
1947                (swf, shf),
1948                0,
1949                &full_page(swf, shf),
1950                vec![("Im0", mask_stream(None))],
1951            ),
1952            (swf, shf),
1953        ));
1954        cases.push((
1955            "mask decode10 rgb".into(),
1956            synth_pdf(
1957                (swf, shf),
1958                0,
1959                &format!("0.9 0.1 0.3 rg {}", full_page(swf, shf)),
1960                vec![("Im0", mask_stream(Some([1.0, 0.0])))],
1961            ),
1962            (swf, shf),
1963        ));
1964        cases.push((
1965            "mask cs sc clip twice".into(),
1966            synth_pdf(
1967                (swf, shf),
1968                0,
1969                &format!(
1970                    "/DeviceGray cs 0.35 sc q 20 10 150 90 re W n {} Q q 60 0 0 40 120 80 cm /Im0 Do Q",
1971                    full_page(swf, shf)
1972                ),
1973                vec![("Im0", mask_stream(None))],
1974            ),
1975            (swf, shf),
1976        ));
1977        cases.push((
1978            "mask rotated page".into(),
1979            synth_pdf(
1980                (swf, shf),
1981                90,
1982                &format!("0 0 1 rg {}", full_page(swf, shf)),
1983                vec![("Im0", mask_stream(None))],
1984            ),
1985            (shf, swf),
1986        ));
1987        cases.push((
1988            "mask interpolate".into(),
1989            synth_pdf(
1990                (swf, shf),
1991                0,
1992                &full_page(swf, shf),
1993                vec![(
1994                    "Im0",
1995                    image_stream(
1996                        sw,
1997                        sh,
1998                        dictionary! { "ImageMask" => true, "Interpolate" => true },
1999                        bits.clone(),
2000                    ),
2001                )],
2002            ),
2003            (swf, shf),
2004        ));
2005
2006        // Indexed and low-depth gray.
2007        let (iw, ih) = (203usize, 131usize);
2008        let indexed = |base: &str, hival: i64, table: Vec<u8>| -> Object {
2009            Object::Array(vec![
2010                "Indexed".into(),
2011                base.into(),
2012                hival.into(),
2013                Object::String(table, lopdf::StringFormat::Hexadecimal),
2014            ])
2015        };
2016        let raw = |label: &str, w: usize, h: usize, dict: Dictionary, data: Vec<u8>| {
2017            (
2018                label.to_string(),
2019                synth_pdf(
2020                    (w as f32, h as f32),
2021                    0,
2022                    &full_page(w as f32, h as f32),
2023                    vec![("Im0", image_stream(w, h, dict, data))],
2024                ),
2025                (w as f32, h as f32),
2026            )
2027        };
2028        cases.push(raw(
2029            "indexed 4-bit rgb",
2030            iw,
2031            ih,
2032            dictionary! { "ColorSpace" => indexed("DeviceRGB", 15, noise(48, 2)), "BitsPerComponent" => 4 },
2033            noise(iw.div_ceil(2) * ih, 1),
2034        ));
2035        cases.push(raw(
2036            "indexed 1-bit two colours",
2037            sw,
2038            sh,
2039            dictionary! { "ColorSpace" => indexed("DeviceRGB", 1, vec![200, 30, 30, 20, 40, 220]), "BitsPerComponent" => 1 },
2040            bits.clone(),
2041        ));
2042        cases.push(raw(
2043            "indexed 8-bit hival 40",
2044            iw,
2045            ih,
2046            dictionary! { "ColorSpace" => indexed("DeviceRGB", 40, noise(123, 3)), "BitsPerComponent" => 8 },
2047            noise(iw * ih, 4),
2048        ));
2049        cases.push(raw(
2050            "indexed 2-bit gray base",
2051            iw,
2052            ih,
2053            dictionary! { "ColorSpace" => indexed("DeviceGray", 3, vec![10, 90, 170, 250]), "BitsPerComponent" => 2 },
2054            noise(iw.div_ceil(4) * ih, 5),
2055        ));
2056        cases.push(raw(
2057            "gray 2-bit",
2058            iw,
2059            ih,
2060            dictionary! { "ColorSpace" => "DeviceGray", "BitsPerComponent" => 2 },
2061            noise(iw.div_ceil(4) * ih, 6),
2062        ));
2063        cases.push(raw(
2064            "gray 4-bit decode10",
2065            iw,
2066            ih,
2067            dictionary! { "ColorSpace" => "DeviceGray", "BitsPerComponent" => 4, "Decode" => reals(&[1.0, 0.0]) },
2068            noise(iw.div_ceil(2) * ih, 7),
2069        ));
2070        cases.push(raw(
2071            "gray 8-bit decode range",
2072            iw,
2073            ih,
2074            dictionary! { "ColorSpace" => "DeviceGray", "BitsPerComponent" => 8, "Decode" => reals(&[0.2, 0.8]) },
2075            noise(iw * ih, 8),
2076        ));
2077        cases.push(raw(
2078            "rgb 8-bit inverted decode",
2079            iw,
2080            ih,
2081            dictionary! {
2082                "ColorSpace" => "DeviceRGB", "BitsPerComponent" => 8,
2083                "Decode" => reals(&[1.0, 0.0, 1.0, 0.0, 1.0, 0.0]),
2084            },
2085            noise(iw * ih * 3, 9),
2086        ));
2087        cases.push(raw(
2088            "calgray 1-bit",
2089            sw,
2090            sh,
2091            dictionary! {
2092                "ColorSpace" => Object::Array(vec![
2093                    "CalGray".into(),
2094                    Object::Dictionary(dictionary! { "WhitePoint" => reals(&[0.9505, 1.0, 1.089]), "Gamma" => 2.2f32 }),
2095                ]),
2096                "BitsPerComponent" => 1,
2097            },
2098            bits.clone(),
2099        ));
2100        cases
2101    }
2102
2103    /// Pages synthesized around every image kind the module reproduces,
2104    /// each compared with pdfium's render at five sizes (down to the
2105    /// reduced-scale JPEG regimes): CCITT G4/G3 as gray and as stencils,
2106    /// Flate stencils with fill colours, clips and a rotated page, Indexed
2107    /// 1/2/4/8-bit, 2/4-bit gray, `/Decode` ranges on gray and RGB. Skipped
2108    /// without `libpdfium`.
2109    #[test]
2110    #[cfg(feature = "pdfium")]
2111    fn synthesized_pages_match_pdfium() {
2112        let mut failures = Vec::new();
2113        let mut compared = 0;
2114        for (label, pdf, (pw, ph)) in synthesized_cases() {
2115            let sizes: Vec<(u32, u32)> = [1.0f32, 2.5, 0.49, 0.24, 0.12]
2116                .iter()
2117                .map(|s| {
2118                    (
2119                        (pw * s).round().max(1.0) as u32,
2120                        (ph * s).round().max(1.0) as u32,
2121                    )
2122                })
2123                .collect();
2124            if !oracle(&label, &pdf, 0, &sizes, &mut failures) {
2125                return;
2126            }
2127            compared += sizes.len();
2128        }
2129        assert!(
2130            failures.is_empty(),
2131            "{}\n({compared} renders compared)",
2132            failures.join("\n")
2133        );
2134        eprintln!("{compared} synthesized renders byte-identical to pdfium");
2135    }
2136
2137    /// Without pdfium: every synthesized kind decodes and draws, and a
2138    /// stencil paints its fill colour.
2139    #[test]
2140    fn synthesized_kinds_render_without_pdfium() {
2141        for (label, pdf, (pw, ph)) in synthesized_cases() {
2142            let meta = PdfMeta::open(&pdf).unwrap();
2143            let (w, h) = ((pw * 0.49).round() as u32, (ph * 0.49).round() as u32);
2144            assert!(
2145                render(&meta, 0, w, h).is_some(),
2146                "{label} declined at {w}x{h}"
2147            );
2148        }
2149        let (sw, sh, bits) = shapes_bits();
2150        let pdf = synth_pdf(
2151            (sw as f32, sh as f32),
2152            0,
2153            &format!("0.2 0.5 0.8 rg {}", full_page(sw as f32, sh as f32)),
2154            vec![(
2155                "Im0",
2156                image_stream(sw, sh, dictionary! { "ImageMask" => true }, bits),
2157            )],
2158        );
2159        let meta = PdfMeta::open(&pdf).unwrap();
2160        let img = render(&meta, 0, sw as u32, sh as u32).expect("stencil mask renders");
2161        let painted = img
2162            .pixels()
2163            .filter(|p| **p == image::Rgb([51, 128, 204]))
2164            .count();
2165        let white = img
2166            .pixels()
2167            .filter(|p| **p == image::Rgb([255, 255, 255]))
2168            .count();
2169        assert!(
2170            painted > 100 && white > 1000,
2171            "painted {painted}, white {white}"
2172        );
2173    }
2174
2175    fn describe(doc: &Document, res: Option<&Dictionary>, content: &[u8], depth: usize) {
2176        let pad = "  ".repeat(depth);
2177        let ops = lopdf::content::Content::decode(content).expect("content");
2178        let mut shown = std::collections::BTreeMap::<String, usize>::new();
2179        for op in &ops.operations {
2180            *shown.entry(op.operator.clone()).or_default() += 1;
2181            if matches!(
2182                op.operator.as_str(),
2183                "cm" | "Do" | "re" | "gs" | "Tr" | "W" | "n"
2184            ) {
2185                println!("{pad}{} {:?}", op.operator, op.operands);
2186            }
2187        }
2188        println!("{pad}ops: {shown:?}");
2189        let Some(res) = res else { return };
2190        let Some(xobjs) = res.get(b"XObject").ok().and_then(|o| as_dict(doc, o)) else {
2191            return;
2192        };
2193        for (name, obj) in xobjs.iter() {
2194            let Some(stream) = deref(doc, obj).as_stream().ok() else {
2195                continue;
2196            };
2197            let mut d = stream.dict.clone();
2198            d.remove(b"Length");
2199            println!("{pad}/{} {:?}", String::from_utf8_lossy(name), d);
2200            if name_is(d.get(b"Subtype").ok(), b"Form") {
2201                let data = stream.decompressed_content().unwrap_or_default();
2202                let fres = d.get(b"Resources").ok().and_then(|o| as_dict(doc, o));
2203                describe(doc, fres, &data, depth + 1);
2204            } else if let Ok(Object::Reference(cs)) = d.get(b"ColorSpace") {
2205                println!("{pad}  ColorSpace -> {:?}", doc.get_object(*cs).ok());
2206            }
2207        }
2208    }
2209
2210    /// Development aid: dump the content streams and XObjects of the scanned
2211    /// fixtures (`cargo test -p docling-pdf --lib raster::tests::dump -- --ignored --nocapture`).
2212    #[test]
2213    #[ignore]
2214    fn dump() {
2215        for rel in IMAGE_ONLY {
2216            let bytes = fixture(rel);
2217            let Some(doc) = crate::textparse::load_document(&bytes) else {
2218                continue;
2219            };
2220            let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
2221            pages.sort_by_key(|(n, _)| *n);
2222            for (n, pid) in pages.into_iter().take(3) {
2223                println!("=== {rel} p{n}");
2224                let content = doc.get_page_content(pid);
2225                let res = doc.get_page_resources(pid).ok().and_then(|(inline, ids)| {
2226                    inline.or_else(|| ids.into_iter().find_map(|id| doc.get_dictionary(id).ok()))
2227                });
2228                describe(&doc, res, &content, 0);
2229            }
2230        }
2231    }
2232}