Skip to main content

docling_pdf/render/
image.rs

1//! Image XObjects and inline images → premultiplied RGBA pixmaps
2//! (ISO 32000-1, 8.9): every bit depth, the colour spaces of [`super::color`],
3//! `/Decode` ranges, stencil masks painted in the fill colour, `/SMask` soft
4//! masks, stencil and colour-key `/Mask`s. The sample data comes through the
5//! raster's filter chain, its libjpeg-exact JPEG decoder and its CCITT
6//! decoder; JPX and JBIG2 have no decoder here and come out as a mid-gray
7//! block (the picture the layout model should see is "a picture").
8//!
9//! Loading ([`load`]: filters, codec, colour space, alpha plane) is separate
10//! from rasterizing ([`rasterize`]: the RGBA pixmap for one destination
11//! size) so a document-wide cache can decode a scan once and blit it at the
12//! pipeline's several scales. Like docling-parse (`build_bitmap_image`), an
13//! image several times larger than its destination is box-averaged down by
14//! an integer factor before the blit, so a 5000-pixel-wide scan drawn 600
15//! pixels wide costs 600 columns.
16
17use std::collections::HashMap;
18
19use lopdf::{Dictionary, Document, Object};
20use tiny_skia::Pixmap;
21
22use super::color::{to_u8, CmykCache, ColorSpace};
23use super::objects::{as_stream, get2, get_bool, get_bool2, get_int, get_int2, nums};
24use crate::raster::{fax, filters, jpeg};
25
26pub struct DecodedImage {
27    pub pixmap: Pixmap,
28    /// Source size before any reduction (the unit square maps to it).
29    pub src_width: u32,
30    pub src_height: u32,
31}
32
33/// Raw samples: rows of `bpc`-bit components, byte-aligned per row.
34struct Samples {
35    width: usize,
36    height: usize,
37    bpc: u32,
38    ncomp: usize,
39    data: Vec<u8>,
40    stride: usize,
41    /// A four-component JPEG with an Adobe APP14 marker.
42    adobe: bool,
43}
44
45impl Samples {
46    fn get(&self, x: usize, y: usize, c: usize) -> u32 {
47        let row = &self.data[y * self.stride..];
48        match self.bpc {
49            8 => u32::from(*row.get(x * self.ncomp + c).unwrap_or(&0)),
50            16 => {
51                let i = (x * self.ncomp + c) * 2;
52                (u32::from(*row.get(i).unwrap_or(&0)) << 8)
53                    | u32::from(*row.get(i + 1).unwrap_or(&0))
54            }
55            bpc => {
56                let bit = (x * self.ncomp + c) * bpc as usize;
57                let byte = *row.get(bit / 8).unwrap_or(&0);
58                u32::from((byte >> (8 - bpc as usize - bit % 8)) & ((1u8 << bpc) - 1))
59            }
60        }
61    }
62
63    fn max(&self) -> f64 {
64        ((1u64 << self.bpc) - 1) as f64
65    }
66}
67
68enum Loaded {
69    Samples(Samples),
70    Placeholder,
71}
72
73/// An image decoded to samples, with everything the rasterizer needs.
74pub struct LoadedImage {
75    width: usize,
76    height: usize,
77    is_mask: bool,
78    decode_arr: Option<Vec<f64>>,
79    samples: Loaded,
80    /// The colour space the samples are in (after the CMYK JPEG rule).
81    cs: Option<ColorSpace>,
82    /// Alpha at source resolution: soft mask, stencil `/Mask`, colour key.
83    alpha: Option<Vec<u8>>,
84}
85
86impl LoadedImage {
87    /// The memory the samples take (for a cache budget).
88    pub fn bytes(&self) -> usize {
89        let s = match &self.samples {
90            Loaded::Samples(s) => s.data.len(),
91            Loaded::Placeholder => 0,
92        };
93        s + self.alpha.as_ref().map_or(0, Vec::len)
94    }
95}
96
97/// Decode `stream` to samples: the filter chain and codec, the colour
98/// space, the alpha plane.
99/// The `/Width` × `/Height` an image dictionary declares (the codec's own
100/// size wins once decoded; this is what the reduction rule is asked with).
101pub fn declared_size(doc: &Document, d: &Dictionary) -> Option<(i64, i64)> {
102    Some((
103        get_int2(doc, d, b"Width", b"W")?,
104        get_int2(doc, d, b"Height", b"H")?,
105    ))
106}
107
108/// docling-parse's `codec_reduction_shift`: how many halvings a JPEG may be
109/// decoded at (libjpeg's 1/2, 1/4, 1/8 reduced inverse DCT) and still hold
110/// at least the pixels it is drawn onto at `target_pixels_per_unit` — the
111/// renderer's `bitmap_target_pixels_per_unit`, docling's `render_scale` of
112/// 1.0 — so the rasterizer keeps minifying and never magnifies samples it
113/// once had. A 300 dpi scan drawn full-page decodes at a quarter for the
114/// 72 dpi hint and is blitted *up* onto the scale-2 canvas; matching that is
115/// what lines the Rust render of a scan up with the shim's. Capped at three
116/// halvings and never below 64 samples a side (a thumbnail is not worth the
117/// resampling risk); `drawn_*_units` is the axis-aligned extent of the drawn
118/// quad in PDF units.
119pub fn codec_reduction_shift(
120    drawn_width_units: f64,
121    drawn_height_units: f64,
122    source_width: i64,
123    source_height: i64,
124    target_pixels_per_unit: f64,
125) -> u32 {
126    const MAX_SHIFT: u32 = 3;
127    const MIN_SOURCE_EXTENT: i64 = 64;
128    if target_pixels_per_unit <= 0.0
129        || source_width <= 0
130        || source_height <= 0
131        || drawn_width_units <= 0.0
132        || drawn_height_units <= 0.0
133    {
134        return 0;
135    }
136    let target_width = drawn_width_units * target_pixels_per_unit;
137    let target_height = drawn_height_units * target_pixels_per_unit;
138    let mut shift = 0u32;
139    while shift < MAX_SHIFT {
140        let next_width = source_width >> (shift + 1);
141        let next_height = source_height >> (shift + 1);
142        if next_width < MIN_SOURCE_EXTENT || next_height < MIN_SOURCE_EXTENT {
143            break;
144        }
145        if (next_width as f64) < target_width || (next_height as f64) < target_height {
146            break;
147        }
148        shift += 1;
149    }
150    shift
151}
152
153/// Whether a reduced decode is allowed at all (docling-parse's
154/// `may_reduce_decode`): not for a stencil mask, an image with a soft mask or
155/// `/Mask` (its alpha plane is resolved on the full grid), or an Indexed image
156/// (its bytes are palette indices — interpolating them means nothing). Only
157/// the DCT decoder acts on the shift; every other codec ignores it.
158fn may_reduce_decode(doc: &Document, d: &Dictionary, res: Option<&Dictionary>) -> bool {
159    if get_bool2(doc, d, b"ImageMask", b"IM").unwrap_or(false) {
160        return false;
161    }
162    if d.has(b"SMask") || d.has(b"Mask") {
163        return false;
164    }
165    !matches!(
166        get2(doc, d, b"ColorSpace", b"CS").and_then(|o| ColorSpace::parse(doc, o, res)),
167        Some(ColorSpace::Indexed { .. })
168    )
169}
170
171/// Decode an image XObject (or inline image) into samples. `reduction_shift`
172/// asks the JPEG decoder for a `1 / 2^shift` reduced decode when the image
173/// allows one ([`codec_reduction_shift`], [`may_reduce_decode`]); the loaded
174/// image then reports the reduced size.
175pub fn load(
176    doc: &Document,
177    stream: &lopdf::Stream,
178    res: Option<&Dictionary>,
179    reduction_shift: u32,
180) -> Result<LoadedImage, String> {
181    let d = &stream.dict;
182    let (width, height) = declared_size(doc, d).ok_or("Width")?;
183    if width <= 0
184        || height <= 0
185        || width > 1 << 16
186        || height > 1 << 16
187        || width * height > 80_000_000
188    {
189        return Err("image size".into());
190    }
191    let is_mask = get_bool2(doc, d, b"ImageMask", b"IM").unwrap_or(false);
192    let decode_arr: Option<Vec<f64>> = get2(doc, d, b"Decode", b"D").and_then(|o| nums(doc, o));
193    let shift = if reduction_shift > 0 && may_reduce_decode(doc, d, res) {
194        reduction_shift
195    } else {
196        0
197    };
198    let mut samples = load_samples(doc, stream, res, is_mask, shift)?;
199    // The decoded samples' size is the image's (a JPEG's own header, or the
200    // reduced decode, wins over the dictionary's `/Width` × `/Height`).
201    let (w, h) = match &samples {
202        Loaded::Samples(s) => (s.width, s.height),
203        Loaded::Placeholder => (width as usize, height as usize),
204    };
205    if is_mask {
206        return Ok(LoadedImage {
207            width: w,
208            height: h,
209            is_mask,
210            decode_arr,
211            samples,
212            cs: None,
213            alpha: None,
214        });
215    }
216    let (cs, alpha) = match &mut samples {
217        Loaded::Placeholder => (None, None),
218        Loaded::Samples(s) => {
219            let cs_obj = get2(doc, d, b"ColorSpace", b"CS");
220            let guess = || match s.ncomp {
221                3 => ColorSpace::DeviceRGB,
222                4 => ColorSpace::DeviceCMYK,
223                _ => ColorSpace::DeviceGray,
224            };
225            let cs = match cs_obj {
226                Some(o) => ColorSpace::parse(doc, o, res).unwrap_or_else(guess),
227                None => guess(),
228            };
229            // A four-component JPEG under a colour space that is not CMYK:
230            // the dictionary contradicts its data, and only the Adobe marker
231            // is left to say what the samples hold — 255 minus the ink
232            // (docling-parse's rule; a /DeviceCMYK image's samples are ink
233            // amounts, and /Decode says otherwise).
234            let cs = if s.ncomp == 4 && cs.components() != 4 {
235                if s.adobe {
236                    for v in &mut s.data {
237                        *v = 255 - *v;
238                    }
239                }
240                ColorSpace::DeviceCMYK
241            } else {
242                cs
243            };
244            let alpha = alpha_plane(doc, d, res, s, w, h)?;
245            (Some(cs), alpha)
246        }
247    };
248    Ok(LoadedImage {
249        width: w,
250        height: h,
251        is_mask,
252        decode_arr,
253        samples,
254        cs,
255        alpha,
256    })
257}
258
259/// Rasterize a loaded image as it would be painted with `fill` (for stencil
260/// masks), reduced so that its whole extent covers about `target` device
261/// pixels (`None` = full size).
262pub fn rasterize(
263    img: &LoadedImage,
264    fill: [u8; 3],
265    target: Option<(u32, u32)>,
266    cmyk: &mut CmykCache,
267) -> Result<DecodedImage, String> {
268    let (w, h) = (img.width, img.height);
269    // Integer reduction factors (docling-parse's `fx = sw / dst_w`).
270    let (fx, fy) = match target {
271        Some((tw, th)) if tw > 0 && th > 0 => (
272            ((w as u32) / tw).max(1) as usize,
273            ((h as u32) / th).max(1) as usize,
274        ),
275        _ => (1, 1),
276    };
277    let out_w = (w / fx).max(1);
278    let out_h = (h / fy).max(1);
279    let mut pixmap = Pixmap::new(out_w as u32, out_h as u32).ok_or("pixmap")?;
280    let done = |pixmap| {
281        Ok(DecodedImage {
282            pixmap,
283            src_width: w as u32,
284            src_height: h as u32,
285        })
286    };
287
288    if img.is_mask {
289        // 1 bpc; sample 0 paints (Decode [0 1]) unless Decode [1 0].
290        let paint_on_one = img
291            .decode_arr
292            .as_ref()
293            .is_some_and(|v| v.first().is_some_and(|x| *x >= 0.5));
294        if let Loaded::Samples(s) = &img.samples {
295            let px = pixmap.pixels_mut();
296            for oy in 0..out_h {
297                for ox in 0..out_w {
298                    let mut cov = 0u32;
299                    let mut n = 0u32;
300                    for y in oy * fy..((oy + 1) * fy).min(h) {
301                        for x in ox * fx..((ox + 1) * fx).min(w) {
302                            let v = s.get(x, y, 0);
303                            let on = (v != 0) == paint_on_one;
304                            cov += u32::from(on);
305                            n += 1;
306                        }
307                    }
308                    let a = (cov * 255).checked_div(n).unwrap_or(0) as u8;
309                    px[oy * out_w + ox] = premul(fill, a);
310                }
311            }
312        }
313        return done(pixmap);
314    }
315
316    let (Loaded::Samples(s), Some(cs)) = (&img.samples, &img.cs) else {
317        // JPX / JBIG2 / undecodable: a neutral block the size of the image.
318        for p in pixmap.pixels_mut() {
319            *p = premul([128, 128, 128], 255);
320        }
321        return done(pixmap);
322    };
323
324    let ncomp = s.ncomp.min(cs.components()).max(1);
325    let max = s.max();
326    let default_decode = cs.default_decode(s.bpc);
327    let decode = match &img.decode_arr {
328        Some(v) if v.len() >= 2 * ncomp => v.clone(),
329        _ => default_decode.clone(),
330    };
331    let is_default_decode = decode
332        .iter()
333        .zip(default_decode.iter())
334        .all(|(a, b)| (a - b).abs() < 1e-9);
335    let alpha = &img.alpha;
336
337    // Fast paths: 8-bit gray / RGB samples with the default decode and no
338    // alpha — the scanned page and the photograph — averaged directly.
339    let direct = alpha.is_none()
340        && s.bpc == 8
341        && is_default_decode
342        && matches!(
343            (cs, s.ncomp),
344            (ColorSpace::DeviceGray, 1) | (ColorSpace::DeviceRGB, 3)
345        );
346    if direct {
347        let px = pixmap.pixels_mut();
348        let nc = s.ncomp;
349        for oy in 0..out_h {
350            for ox in 0..out_w {
351                let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32);
352                for y in oy * fy..((oy + 1) * fy).min(h) {
353                    let row = &s.data[y * s.stride..];
354                    for x in ox * fx..((ox + 1) * fx).min(w) {
355                        let i = x * nc;
356                        if nc == 1 {
357                            let v = u32::from(row[i]);
358                            r += v;
359                            g += v;
360                            b += v;
361                        } else {
362                            r += u32::from(row[i]);
363                            g += u32::from(row[i + 1]);
364                            b += u32::from(row[i + 2]);
365                        }
366                        n += 1;
367                    }
368                }
369                let n = n.max(1);
370                px[oy * out_w + ox] = tiny_skia::PremultipliedColorU8::from_rgba(
371                    (r / n) as u8,
372                    (g / n) as u8,
373                    (b / n) as u8,
374                    255,
375                )
376                .unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
377            }
378        }
379        return done(pixmap);
380    }
381
382    // Sample tuple → RGB, memoised (indexed, gray, CMYK and low depths repeat).
383    let mut memo: HashMap<u64, [u8; 3]> = HashMap::new();
384    let mut convert = |vals: &[u32]| -> [u8; 3] {
385        let key = vals
386            .iter()
387            .take(4)
388            .fold(0u64, |a, &v| (a << 16) | u64::from(v & 0xFFFF));
389        if let Some(c) = memo.get(&key) {
390            return *c;
391        }
392        let rgb = match (cs, s.bpc, is_default_decode) {
393            (ColorSpace::DeviceRGB, 8, true) => [vals[0] as u8, vals[1] as u8, vals[2] as u8],
394            (ColorSpace::DeviceGray, 8, true) => [vals[0] as u8; 3],
395            (ColorSpace::DeviceCMYK, 8, true) => {
396                cmyk.rgb8(vals[0] as u8, vals[1] as u8, vals[2] as u8, vals[3] as u8)
397            }
398            _ => {
399                let comps: Vec<f64> = (0..ncomp)
400                    .map(|i| {
401                        let (dmin, dmax) = (decode[2 * i], decode[2 * i + 1]);
402                        dmin + f64::from(vals[i]) * (dmax - dmin) / max
403                    })
404                    .collect();
405                match cs.to_rgb(&comps) {
406                    Some(c) => [to_u8(c[0]), to_u8(c[1]), to_u8(c[2])],
407                    None => [255, 255, 255],
408                }
409            }
410        };
411        if memo.len() < 1 << 18 {
412            memo.insert(key, rgb);
413        }
414        rgb
415    };
416
417    let px = pixmap.pixels_mut();
418    let mut vals = vec![0u32; ncomp.max(4)];
419    for oy in 0..out_h {
420        for ox in 0..out_w {
421            let (mut r, mut g, mut b, mut a, mut n) = (0u32, 0u32, 0u32, 0u32, 0u32);
422            for y in oy * fy..((oy + 1) * fy).min(h) {
423                for x in ox * fx..((ox + 1) * fx).min(w) {
424                    for (c, v) in vals.iter_mut().enumerate().take(ncomp) {
425                        *v = s.get(x, y, c);
426                    }
427                    let rgb = convert(&vals[..ncomp.max(1)]);
428                    let al = match alpha {
429                        Some(pl) => u32::from(pl[y * w + x]),
430                        None => 255,
431                    };
432                    // Premultiply before averaging, like docling-parse.
433                    r += u32::from(rgb[0]) * al / 255;
434                    g += u32::from(rgb[1]) * al / 255;
435                    b += u32::from(rgb[2]) * al / 255;
436                    a += al;
437                    n += 1;
438                }
439            }
440            let n = n.max(1);
441            px[oy * out_w + ox] = tiny_skia::PremultipliedColorU8::from_rgba(
442                (r / n).min(255) as u8,
443                (g / n).min(255) as u8,
444                (b / n).min(255) as u8,
445                (a / n).min(255) as u8,
446            )
447            .unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
448        }
449    }
450    done(pixmap)
451}
452
453/// [`load`] then [`rasterize`].
454pub fn decode(
455    doc: &Document,
456    stream: &lopdf::Stream,
457    res: Option<&Dictionary>,
458    fill: [u8; 3],
459    target: Option<(u32, u32)>,
460    cmyk: &mut CmykCache,
461) -> Result<DecodedImage, String> {
462    let img = load(doc, stream, res, 0)?;
463    rasterize(&img, fill, target, cmyk)
464}
465
466fn premul(rgb: [u8; 3], a: u8) -> tiny_skia::PremultipliedColorU8 {
467    let m = |c: u8| ((u32::from(c) * u32::from(a) + 127) / 255) as u8;
468    tiny_skia::PremultipliedColorU8::from_rgba(m(rgb[0]), m(rgb[1]), m(rgb[2]), a)
469        .unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT)
470}
471
472/// Run the filter chain and the image codec; `Placeholder` for JPX/JBIG2.
473fn load_samples(
474    doc: &Document,
475    stream: &lopdf::Stream,
476    res: Option<&Dictionary>,
477    is_mask: bool,
478    reduction_shift: u32,
479) -> Result<Loaded, String> {
480    let d = &stream.dict;
481    let w = get_int2(doc, d, b"Width", b"W").unwrap_or(0).max(0) as usize;
482    let h = get_int2(doc, d, b"Height", b"H").unwrap_or(0).max(0) as usize;
483    let chain = filters::filters(doc, d);
484    let (data, codec) =
485        filters::apply(doc, &stream.content, &chain).map_err(|e| format!("filter {e:?}"))?;
486    let bpc_dict = if is_mask {
487        1
488    } else {
489        get_int2(doc, d, b"BitsPerComponent", b"BPC")
490            .unwrap_or(8)
491            .clamp(1, 16) as u32
492    };
493    let cs_ncomp = if is_mask {
494        1
495    } else {
496        get2(doc, d, b"ColorSpace", b"CS")
497            .and_then(|o| ColorSpace::parse(doc, o, res))
498            .map(|c| c.components())
499            .unwrap_or(1)
500    };
501    match codec {
502        None => {
503            let bpc = match bpc_dict {
504                1 | 2 | 4 | 8 | 16 => bpc_dict,
505                _ => 8,
506            };
507            let stride = (w * cs_ncomp * bpc as usize).div_ceil(8);
508            let mut data = data;
509            if data.len() < stride * h {
510                // Short data: pad (a truncated image shows what it has).
511                data.resize(stride * h, 0);
512            }
513            Ok(Loaded::Samples(Samples {
514                width: w,
515                height: h,
516                bpc,
517                ncomp: cs_ncomp,
518                data,
519                stride,
520                adobe: false,
521            }))
522        }
523        Some(codec) if codec.name == "DCTDecode" => {
524            let transform = codec
525                .parms
526                .as_ref()
527                .and_then(|p| get_int(doc, p, b"ColorTransform"))
528                .unwrap_or(1)
529                != 0;
530            match jpeg::decode(&data, transform, 1u32 << reduction_shift.min(3)) {
531                Ok(img) => {
532                    let ncomp = img.channels;
533                    // The JPEG's own size wins over the dictionary's.
534                    Ok(Loaded::Samples(Samples {
535                        width: img.width,
536                        height: img.height,
537                        bpc: 8,
538                        ncomp,
539                        stride: img.width * ncomp,
540                        adobe: img.adobe_inverted,
541                        data: img.data,
542                    }))
543                }
544                Err(e) => {
545                    docling_core::debug_log!("docling-pdf render: JPEG not decoded ({e:?})");
546                    Ok(Loaded::Placeholder)
547                }
548            }
549        }
550        Some(codec) if codec.name == "CCITTFaxDecode" => {
551            let p = codec.parms.as_ref();
552            let pi = |k: &[u8], default: i64| p.and_then(|p| get_int(doc, p, k)).unwrap_or(default);
553            let pb = |k: &[u8]| p.and_then(|p| get_bool(doc, p, k)).unwrap_or(false);
554            let mut rows = pi(b"Rows", 0);
555            if rows > i64::from(u16::MAX) {
556                rows = 0;
557            }
558            let params = fax::Params {
559                k: pi(b"K", 0) as i32,
560                end_of_line: pb(b"EndOfLine"),
561                byte_align: pb(b"EncodedByteAlign"),
562                black_is_1: pb(b"BlackIs1"),
563                columns: pi(b"Columns", 1728).clamp(1, 65535) as usize,
564                rows: rows.max(0) as usize,
565            };
566            let lines = fax::decode(&data, &params, h);
567            let stride = w.div_ceil(8);
568            let mut out = vec![0u8; stride * h];
569            for (y, row) in out.chunks_exact_mut(stride).enumerate() {
570                match lines.get(y) {
571                    Some(Some(line)) => {
572                        let n = stride.min(line.len());
573                        row[..n].copy_from_slice(&line[..n]);
574                    }
575                    // Past the data: white (CCITT 1 = white unless BlackIs1).
576                    _ => row.fill(if params.black_is_1 { 0x00 } else { 0xff }),
577                }
578            }
579            Ok(Loaded::Samples(Samples {
580                width: w,
581                height: h,
582                bpc: 1,
583                ncomp: 1,
584                data: out,
585                stride,
586                adobe: false,
587            }))
588        }
589        Some(codec) => {
590            docling_core::debug_log!(
591                "docling-pdf render: no {} decoder, drawing a placeholder",
592                codec.name
593            );
594            Ok(Loaded::Placeholder)
595        }
596    }
597}
598
599/// Take a mask's samples (any bit depth) as an alpha plane at the image size:
600/// the soft mask's gray, a stencil `/Mask`'s 1 = masked out, or the colour
601/// key ranges on the raw samples.
602fn alpha_plane(
603    doc: &Document,
604    d: &Dictionary,
605    res: Option<&Dictionary>,
606    s: &Samples,
607    w: usize,
608    h: usize,
609) -> Result<Option<Vec<u8>>, String> {
610    // Soft mask.
611    if let Some(sm) = d.get(b"SMask").ok().and_then(|o| as_stream(doc, o)) {
612        let smw = get_int(doc, &sm.dict, b"Width").unwrap_or(0).max(0) as usize;
613        let smh = get_int(doc, &sm.dict, b"Height").unwrap_or(0).max(0) as usize;
614        if smw > 0 && smh > 0 && smw * smh <= 80_000_000 {
615            if let Ok(Loaded::Samples(ms)) = load_samples(doc, sm, res, false, 0) {
616                let decode = get2(doc, &sm.dict, b"Decode", b"D").and_then(|o| nums(doc, o));
617                let invert = decode.is_some_and(|v| v.first().is_some_and(|x| *x >= 0.5));
618                let mmax = ms.max();
619                let mut plane = vec![255u8; w * h];
620                for y in 0..h {
621                    let my = (y * ms.height / h.max(1)).min(ms.height.saturating_sub(1));
622                    for x in 0..w {
623                        let mx = (x * ms.width / w.max(1)).min(ms.width.saturating_sub(1));
624                        let v = f64::from(ms.get(mx, my, 0)) / mmax;
625                        let v = if invert { 1.0 - v } else { v };
626                        plane[y * w + x] = (v * 255.0).round() as u8;
627                    }
628                }
629                return Ok(Some(plane));
630            }
631        }
632    }
633    match d.get(b"Mask").ok().map(|o| super::objects::deref(doc, o)) {
634        // Stencil mask: 1 bpc, sample 1 = masked (unless Decode [1 0]).
635        Some(Object::Stream(ms)) => {
636            let mw = get_int(doc, &ms.dict, b"Width").unwrap_or(0).max(0) as usize;
637            let mh = get_int(doc, &ms.dict, b"Height").unwrap_or(0).max(0) as usize;
638            if mw > 0 && mh > 0 && mw * mh <= 80_000_000 {
639                if let Ok(Loaded::Samples(bits)) = load_samples(doc, ms, res, true, 0) {
640                    let decode = get2(doc, &ms.dict, b"Decode", b"D").and_then(|o| nums(doc, o));
641                    let one_paints = decode.is_some_and(|v| v.first().is_some_and(|x| *x >= 0.5));
642                    let mut plane = vec![255u8; w * h];
643                    for y in 0..h {
644                        let my = (y * bits.height / h.max(1)).min(bits.height.saturating_sub(1));
645                        for x in 0..w {
646                            let mx = (x * bits.width / w.max(1)).min(bits.width.saturating_sub(1));
647                            let v = bits.get(mx, my, 0) != 0;
648                            let masked = if one_paints { !v } else { v };
649                            if masked {
650                                plane[y * w + x] = 0;
651                            }
652                        }
653                    }
654                    return Ok(Some(plane));
655                }
656            }
657            Ok(None)
658        }
659        // Colour key: ranges per component on the raw integer samples.
660        Some(Object::Array(ranges)) => {
661            let r: Vec<i64> = ranges
662                .iter()
663                .filter_map(|o| {
664                    super::objects::num(super::objects::deref(doc, o)).map(|v| v as i64)
665                })
666                .collect();
667            let n = s.ncomp;
668            if r.len() < 2 * n {
669                return Ok(None);
670            }
671            let mut plane = vec![255u8; w * h];
672            for y in 0..h.min(s.height) {
673                for x in 0..w.min(s.width) {
674                    let masked = (0..n).all(|c| {
675                        let v = i64::from(s.get(x, y, c));
676                        v >= r[2 * c] && v <= r[2 * c + 1]
677                    });
678                    if masked {
679                        plane[y * w + x] = 0;
680                    }
681                }
682            }
683            Ok(Some(plane))
684        }
685        _ => Ok(None),
686    }
687}