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