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