Skip to main content

pdfrum_page/image/
mod.rs

1//! Image `XObject`s: from a stream to pixels (ISO 32000-1 §8.9).
2//!
3//! The load is a ladder, and every rung has a failure mode worth knowing:
4//!
5//! 1. **Validate the dictionary** — dimensions, bit depth, the two
6//!    filter-driven coercions. There is no "repair a bad bit depth to eight";
7//!    see [`dict`].
8//! 2. **Resolve the colour space**, consulting form resources only for inline
9//!    images.
10//! 3. **Build the decode mapping** from `/Decode`, or the space's defaults.
11//! 4. **Run the codec** the last filter names, or read raw samples.
12//! 5. **Load the mask**, where `/SMask` beats `/Mask` and a mask that fails
13//!    to load is simply dropped rather than failing the image.
14//!
15//! # Owned pixels, not a lazy scanline source
16//!
17//! PDFium produces scanlines on demand from three mutable scratch buffers.
18//! We hold an owned [`ImageData`] instead: every
19//! per-scanline *behaviour* is preserved — the truncated-stream zero pad, the
20//! palette packing, the sixteen-bit high-byte truncation — and only the
21//! laziness is gone. Memory is bounded by the same four-gibibyte cap the C++
22//! enforces.
23
24mod bitimage;
25mod cache;
26mod dct;
27mod decode_array;
28mod dict;
29#[cfg(feature = "jbig2")]
30mod jbig2;
31#[cfg(feature = "jpeg2000")]
32mod jpx;
33mod mask;
34mod packed;
35mod rows;
36mod scanline;
37
38pub use bitimage::BitImage;
39pub use cache::{ImageCache, MAX_BYTES, RequestedSize};
40pub(crate) use dct::decode_dct;
41pub(crate) use decode_array::DecodeMap;
42pub(crate) use dict::ImageDict;
43#[cfg(feature = "jbig2")]
44pub use jbig2::decode_jbig2;
45#[cfg(feature = "jpeg2000")]
46pub(crate) use jpx::SpaceOverride;
47#[cfg(feature = "jpeg2000")]
48pub use jpx::{JpxImage, decode_jpx};
49pub use mask::ImageMask;
50pub(crate) use mask::{ColorKey, matte_color};
51pub use packed::{Depth, Packed, Unpacked};
52pub use rows::{Converted, Palette, Rgb8, Rgba8, Row, Rows, Source};
53
54use crate::color::{ColorSpace, Rgb};
55use crate::error::Error;
56use crate::function::FunctionCache;
57use crate::names;
58use pdfrum_common::{DiagKind, Diagnostics, Limits, Severity};
59#[cfg(feature = "ccitt")]
60use pdfrum_filters::{CcittParams, decode_ccitt};
61use pdfrum_filters::{Filter, decode_chain};
62use pdfrum_object::{Dict, Object, Resolve, Stream};
63
64/// Largest pixel grid (`width × height`) this crate will materialize from
65/// file-declared dimensions.
66///
67/// The dictionary gate bounds each axis at 131071; 131071 square is inside
68/// that bound and is 17 gigapixels. A gigapixel is roughly twice the largest
69/// image anything real produces (a 600 dpi A0 scan is 0.56 Gpx). Above it a
70/// mask is dropped and a pixmap is not built — see [`image_area_is_workable`].
71///
72/// The reduction pre-pass and `to_pixmap` use this same predicate, so a size
73/// one path refuses the other does not walk.
74pub const MAX_IMAGE_PIXELS: u64 = 1 << 30;
75
76/// Whether a file-declared width and height are small enough to convert,
77/// reduce, or unpack into a dense plane.
78///
79/// Both axes come from the file. The product is what has to be asked about.
80#[must_use]
81pub fn image_area_is_workable(width: u32, height: u32) -> bool {
82    u64::from(width).saturating_mul(u64::from(height)) <= MAX_IMAGE_PIXELS
83}
84
85/// Decoded pixels, in whichever shape the source produced.
86///
87/// Keeping the shape rather than always widening to RGB matters: an indexed
88/// image's palette is what a renderer needs to resample correctly, and a
89/// one-bit stencil is a mask, not a picture.
90#[derive(Debug, Clone, PartialEq)]
91#[non_exhaustive]
92pub enum Pixels {
93    /// One bit per pixel, packed MSB-first with byte-aligned rows. A set bit
94    /// paints; this is what a stencil mask produces.
95    Stencil(BitImage),
96    /// Eight-bit grey.
97    Gray8(Box<[u8]>),
98    /// Eight-bit red, green, blue.
99    Rgb8(Box<[u8]>),
100    /// Eight-bit cyan, magenta, yellow, black.
101    Cmyk8(Box<[u8]>),
102    /// Palette indices with the palette to resolve them.
103    Indexed {
104        /// One index per pixel.
105        indices: Box<[u8]>,
106        /// The resolved colours, one per index value.
107        palette: Box<[Rgb]>,
108    },
109}
110
111impl Pixels {
112    /// How many components each pixel carries.
113    #[must_use]
114    pub fn components(&self) -> usize {
115        match self {
116            Self::Stencil(_) | Self::Gray8(_) | Self::Indexed { .. } => 1,
117            Self::Rgb8(_) => 3,
118            Self::Cmyk8(_) => 4,
119        }
120    }
121
122    /// Bytes held.
123    #[must_use]
124    pub fn byte_size(&self) -> usize {
125        match self {
126            Self::Stencil(b) => b.bits.len(),
127            Self::Gray8(d) | Self::Rgb8(d) | Self::Cmyk8(d) => d.len(),
128            Self::Indexed { indices, palette } => {
129                indices.len() + palette.len() * std::mem::size_of::<Rgb>()
130            }
131        }
132    }
133}
134
135/// An image's samples, in whichever state the decode ladder left them.
136///
137/// The two states are genuinely different things, not one thing with a flag.
138/// [`Samples::Packed`] is what every path that does not run a codec of its own
139/// produces: the filter chain's bytes, still at the dictionary's
140/// `/BitsPerComponent`, which the row pipeline widens as it walks
141/// ([`Unpacked`]). [`Samples::Whole`] is what a codec produced — DCT, JPEG
142/// 2000 and JBIG2 all hand back eight-bit samples — together with the two
143/// arms that cannot be lazy at all: a stencil's bits, and the palettes an
144/// indexed or tint image resolves once.
145///
146/// Nothing widens a packed image until someone asks for whole-image
147/// [`Pixels`], which is [`Samples::to_pixels`] and nowhere else.
148#[derive(Debug, Clone, PartialEq)]
149#[non_exhaustive]
150pub enum Samples {
151    /// Still packed, walked by [`Unpacked`].
152    Packed(Packed),
153    /// Already one byte per component, or a shape that has no packed form.
154    Whole(Pixels),
155}
156
157impl Samples {
158    /// How many components each pixel carries.
159    #[must_use]
160    pub fn components(&self) -> usize {
161        match self {
162            Self::Packed(p) => p.components(),
163            Self::Whole(p) => p.components(),
164        }
165    }
166
167    /// Bytes held, for the render cache's budget.
168    ///
169    /// Honest about what is *actually* held: a packed image is its packed
170    /// bytes and its decode table, which is what the cache is keeping alive,
171    /// and is smaller than the widened form it never builds.
172    #[must_use]
173    pub fn byte_size(&self) -> usize {
174        match self {
175            Self::Packed(p) => p.byte_size(),
176            Self::Whole(p) => p.byte_size(),
177        }
178    }
179
180    /// Whether these samples are a stencil mask's bits.
181    ///
182    /// A stencil never has a packed form — its bits are its representation —
183    /// so this is a question about the [`Whole`](Self::Whole) arm alone.
184    #[must_use]
185    pub const fn is_stencil(&self) -> bool {
186        matches!(self, Self::Whole(Pixels::Stencil(_)))
187    }
188
189    /// The resolved palette, when these samples are indices into one.
190    ///
191    /// Only [`Pixels::Indexed`] has one; every other representation carries
192    /// its colours in the samples themselves.
193    #[must_use]
194    pub fn palette(&self) -> Option<&[Rgb]> {
195        match self {
196            Self::Whole(Pixels::Indexed { palette, .. }) => Some(palette),
197            _ => None,
198        }
199    }
200
201    /// The whole image as [`Pixels`], widening a packed one if it has to.
202    ///
203    /// The one place the full-size buffer the row pipeline exists to avoid is
204    /// built. Three callers want it and no more: the CLI's image export, the
205    /// facade's edit path, and a test that names the representation.
206    #[must_use]
207    pub fn to_pixels(&self) -> Pixels {
208        match self {
209            Self::Whole(p) => p.clone(),
210            Self::Packed(p) => {
211                let data = Unpacked::new(p).collect_all();
212                match p.components() {
213                    1 => Pixels::Gray8(data),
214                    4 => Pixels::Cmyk8(data),
215                    _ => Pixels::Rgb8(data),
216                }
217            }
218        }
219    }
220}
221
222/// A fully decoded image.
223#[derive(Debug, Clone, PartialEq)]
224pub struct ImageData {
225    /// Width in samples, from the codec when it disagreed with the
226    /// dictionary.
227    pub width: u32,
228    /// Height in samples.
229    pub height: u32,
230    /// The samples, in whichever state the decode left them.
231    pub samples: Samples,
232    /// The alpha, however it was expressed.
233    pub mask: Option<ImageMask>,
234    /// The `/Matte` colour a pre-blended soft-masked image was composed
235    /// against.
236    pub matte: Option<Rgb>,
237    /// `/Interpolate`, a hint the renderer may honour.
238    pub interpolate: bool,
239}
240
241impl ImageData {
242    /// Bytes held, for the cache's budget.
243    #[must_use]
244    pub fn byte_size(&self) -> usize {
245        self.samples.byte_size()
246            + match &self.mask {
247                Some(ImageMask::Alpha { alpha, .. }) => alpha.len(),
248                _ => 0,
249            }
250    }
251}
252
253/// Decode an image `XObject`.
254///
255/// `form_resources` is searched for a named colour space before
256/// `page_resources`, and the interpreter passes it only for an inline image:
257/// a real `XObject` sees the page's resources alone. `size` says how much
258/// resolution the caller needs, which only the DCT and JPEG 2000 codecs act
259/// on.
260///
261/// # Errors
262///
263/// [`Error::ImageBadDict`] for a dictionary that will not validate,
264/// [`Error::ImageNoColorSpace`] when a non-mask image has no usable space,
265/// [`Error::ImageUndecodable`] when no codec can produce samples, and
266/// [`Error::CodecRejected`] when one tried and failed.
267#[expect(
268    clippy::too_many_arguments,
269    reason = "the image ladder genuinely needs the stream, both resource \
270              dictionaries, the requested size, the resolver, the function \
271              cache, limits and diagnostics"
272)]
273#[expect(
274    clippy::too_many_lines,
275    reason = "the load ladder reads as one sequence; splitting it would hide \
276              the order the rungs run in"
277)]
278pub fn decode_image<R: Resolve>(
279    stream: &Stream,
280    form_resources: Option<&Dict>,
281    page_resources: Option<&Dict>,
282    size: RequestedSize,
283    r: &R,
284    functions: &mut FunctionCache,
285    limits: &Limits,
286    diags: &mut Diagnostics,
287) -> Result<ImageData, Error> {
288    let info = ImageDict::load(&stream.dict, r, diags)?;
289
290    // A stencil mask needs no colour space at all.
291    if info.image_mask {
292        return decode_stencil(stream, &info, r, limits, diags);
293    }
294
295    let space = resolve_space(
296        &stream.dict,
297        form_resources,
298        page_resources,
299        r,
300        functions,
301        limits,
302        diags,
303    );
304    let components = info
305        .components
306        .max(u32::try_from(space.as_ref().map_or(0, ColorSpace::n_components)).unwrap_or(0));
307    let info = ImageDict { components, ..info };
308
309    let decoded = decode_chain(stream, info.total_bytes().unwrap_or(0), r, limits, diags);
310
311    // The codecs, dispatched on the last filter. The requested size reaches
312    // only the JPEG 2000 decoder, which is the one that carries a pyramid.
313    #[cfg(not(feature = "jpeg2000"))]
314    let _ = size;
315    let (width, height, samples, jpx_alpha) = match info.last_filter {
316        #[cfg(feature = "jpeg2000")]
317        Some(Filter::Jpx) => {
318            let smask_in_data = stream.dict.int(names::SMASK_IN_DATA, r).unwrap_or(0);
319            // The request goes to the codec whole rather than as a level
320            // count: JPEG 2000 carries the pyramid, so the decoder is the one
321            // that knows how many levels it has to give.
322            let image = decode_jpx(&decoded.data, space.as_ref(), smask_in_data, size, limits)
323                .inspect_err(|_| {
324                    diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
325                })?;
326            if image.space_override != SpaceOverride::Keep {
327                diags.record(Severity::Recovered, DiagKind::JpxColorSpaceOverride, None);
328            }
329            let pixels = match (&space, image.components) {
330                // An `/Indexed` space keeps its indices and a resolved
331                // palette. The decoder was asked for raw indices rather than
332                // colours (`JpxAction::UseIndexed`), but it still hands them
333                // back as **eight-bit** samples, so a `/BitsPerComponent`
334                // below eight has to be shifted back down:
335                //
336                // ```
337                // } else if (color_space_ && family == kIndexed && bpc_ < 8) {
338                //   int scale = 8 - bpc_;
339                //   for (auto& pixel : scanline) { pixel >>= scale; }
340                // }
341                // ```
342                //
343                // Without it every sample overshoots the palette and clamps
344                // to its last entry; with the palette dropped altogether the
345                // indices themselves reach the page as grey, which is what
346                // `jpxdecode_indexed.in` rendered before.
347                (Some(cs @ ColorSpace::Indexed(indexed)), 1) => {
348                    // The shift runs only for `bpc_ < 8`, so a declared depth
349                    // of eight or more leaves the samples alone. A *zero*
350                    // depth is the no-colour-space JPX path, where PDFium
351                    // would shift by eight and clear every index; we keep the
352                    // same answer without the overflowing shift.
353                    let indices: Box<[u8]> = if info.bpc >= 8 {
354                        image.data.iter().copied().collect()
355                    } else {
356                        let scale = 8u32.saturating_sub(info.bpc);
357                        image
358                            .data
359                            .iter()
360                            .map(|&v| u8::try_from(u32::from(v) >> scale).unwrap_or(0))
361                            .collect()
362                    };
363                    let palette = (0..=indexed.max_index)
364                        .map(|i| cs.to_rgb(&[f32::from(i)]))
365                        .collect();
366                    Pixels::Indexed { indices, palette }
367                }
368                (_, 1) => Pixels::Gray8(image.data.into()),
369                (_, 4) => Pixels::Cmyk8(image.data.into()),
370                _ => Pixels::Rgb8(image.data.into()),
371            };
372            (
373                image.width,
374                image.height,
375                Samples::Whole(pixels),
376                image.alpha,
377            )
378        }
379        #[cfg(not(feature = "jpeg2000"))]
380        Some(Filter::Jpx) => {
381            diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
382            return Err(Error::ImageUndecodable {
383                what: concat!("this build has no ", "Jpx", " decoder (feature `jpeg2000`)"),
384            });
385        }
386        #[cfg(feature = "jbig2")]
387        Some(Filter::Jbig2) => {
388            let globals = info
389                .params
390                .stream(names::JBIG2_GLOBALS, r)
391                // Absent or unfetchable globals are silently tolerated.
392                .map(|s| decode_chain(&s, 0, r, limits, diags).data);
393            let bits = decode_jbig2(
394                globals.as_deref(),
395                &decoded.data,
396                info.width,
397                info.height,
398                limits,
399            )
400            .inspect_err(|_| {
401                diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
402            })?;
403            // A JBIG2 codestream is bi-level, but that does not make every
404            // JBIG2 image a *stencil*. One that declares a `/ColorSpace` and
405            // does not declare `/ImageMask` is an ordinary one-bit picture,
406            // and PDFium draws it as one: `Jbig2Decoder::Decode` finishes with
407            // `pix = ~pix` over the whole buffer (`jbig2_decoder.cpp:33`),
408            // turning JBIG2's "1 means black" into the PDF sample convention
409            // where 0 is black, and the result then goes through the ordinary
410            // colour-space and `/Decode` path like any other 1-bit image.
411            //
412            // The bit layout is already right: a one-bit, one-component row is
413            // `width.div_ceil(8)` bytes, which is exactly `ImageDict::pitch`.
414            if info.image_mask {
415                (
416                    info.width,
417                    info.height,
418                    Samples::Whole(Pixels::Stencil(bits)),
419                    None,
420                )
421            } else {
422                let mut samples = bits.bits;
423                for byte in &mut samples {
424                    *byte = !*byte;
425                }
426                let samples = unpack(&info, space.as_ref(), &samples, diags)?;
427                (info.width, info.height, samples, None)
428            }
429        }
430        #[cfg(not(feature = "jbig2"))]
431        Some(Filter::Jbig2) => {
432            diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
433            return Err(Error::ImageUndecodable {
434                what: concat!("this build has no ", "Jbig2", " decoder (feature `jbig2`)"),
435            });
436        }
437        Some(Filter::Dct) => {
438            let image = decode_dct(&decoded.data, (info.width, info.height)).inspect_err(|_| {
439                diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
440            })?;
441            // The codec's dimensions **override** the dictionary's.
442            if image.width != info.width || image.height != info.height {
443                diags.record(
444                    Severity::Recovered,
445                    DiagKind::ImageDimensionsFromCodec,
446                    None,
447                );
448            }
449            if !dct::component_mismatch_allowed(space.as_ref(), image.components) {
450                return Err(Error::ImageUndecodable {
451                    what: "JPEG component count disagrees with the colour space",
452                });
453            }
454            let mut data = image.data;
455            apply_codec_decode(&mut data, space.as_ref(), image.components, &info);
456            let pixels = match image.components {
457                1 => Pixels::Gray8(data.into()),
458                4 => Pixels::Cmyk8(data.into()),
459                _ => Pixels::Rgb8(data.into()),
460            };
461            (image.width, image.height, Samples::Whole(pixels), None)
462        }
463        // A one-bit fax image with a colour space of its own: the bits are the
464        // samples, so they go through `unpack` like any other 1-bit picture and
465        // pick up `/Decode` and the palette on the way.
466        Some(Filter::CcittFax) => {
467            let samples = ccitt_samples(&info, &decoded.data, r, limits, diags)?;
468            let samples = unpack(&info, space.as_ref(), &samples, diags)?;
469            (info.width, info.height, samples, None)
470        }
471        _ => {
472            if decoded.image.is_some() && info.last_filter.is_none() {
473                return Err(Error::ImageUndecodable {
474                    what: "an unrecognised filter left no decoder",
475                });
476            }
477            let samples = unpack(&info, space.as_ref(), &decoded.data, diags)?;
478            (info.width, info.height, samples, None)
479        }
480    };
481
482    // `/SMask` wins over `/Mask` at every level: when it is present the
483    // colour-key array is never even read.
484    let mask = load_mask(
485        &stream.dict,
486        &info,
487        space.as_ref(),
488        jpx_alpha,
489        r,
490        functions,
491        limits,
492        diags,
493    );
494
495    // A colour key is a *predicate on raw samples*, so it has to be resolved
496    // while they are still in hand. PDFium does this inside `GetScanline`,
497    // writing `alpha = out_of_range ? 0xFF : 0` beside each pixel; here the
498    // pixels are already unpacked, so it is one more pass over the same
499    // scanlines. See [`resolve_color_key`].
500    let mask = match mask {
501        Some(ImageMask::ColorKey(key)) => {
502            resolve_color_key(&key, &info, &decoded.data, width, height)
503        }
504        other => other,
505    };
506
507    let matte = matte_color(
508        stream.dict.array(names::MATTE, r).as_ref(),
509        space.as_ref(),
510        usize::try_from(info.components).unwrap_or(0),
511    );
512
513    Ok(ImageData {
514        width,
515        height,
516        samples,
517        mask,
518        matte,
519        interpolate: stream.dict.bool(names::INTERPOLATE).unwrap_or(false),
520    })
521}
522
523/// Turn a colour key into the alpha plane it implies.
524///
525/// A `/Mask` array names, per component, a closed range of **raw sample**
526/// values that is transparent — so the test cannot be run on the decoded
527/// colours, and it cannot be run at draw time either, because by then the
528/// samples are gone. It runs at unpack time instead, beside the scanline that
529/// still has the raw samples, and produces an alpha plane where **a sample
530/// inside the named range is transparent** — opaque everywhere else.
531/// `bug_343075986.in` masks index 0
532/// out of an indexed image so a yellow background shows through; without this
533/// the index paints its palette entry, which is black.
534///
535/// Returns `None` when the key covers nothing, which leaves the image opaque
536/// rather than inventing a fully-opaque plane to carry.
537fn resolve_color_key(
538    key: &ColorKey,
539    info: &ImageDict,
540    data: &[u8],
541    width: u32,
542    height: u32,
543) -> Option<ImageMask> {
544    let components = usize::try_from(info.components).unwrap_or(0);
545    if components == 0 || info.bpc == 0 || key.ranges.is_empty() {
546        return None;
547    }
548    let pitch = info.pitch()?;
549    let pixels_per_row = usize::try_from(width).ok()?;
550    let rows = usize::try_from(height).ok()?;
551    let mut alpha = vec![255u8; pixels_per_row.checked_mul(rows)?];
552    let mut samples = vec![0u32; components];
553    let mut any = false;
554    for y in 0..rows {
555        let (line, availability) = scanline::scanline(data, u32::try_from(y).unwrap_or(0), pitch);
556        // An absent row's samples are all zero, and PDFium's zeroed-output arm
557        // returns before the colour-key pass too, so it stays opaque.
558        if availability == scanline::Availability::Absent {
559            continue;
560        }
561        for x in 0..pixels_per_row {
562            for (c, slot) in samples.iter_mut().enumerate() {
563                let bit_pos = (x * components + c) * info.bpc as usize;
564                *slot = scanline::get_bits(&line, bit_pos, info.bpc);
565            }
566            if key.is_transparent(&samples)
567                && let Some(a) = alpha.get_mut(y * pixels_per_row + x)
568            {
569                *a = 0;
570                any = true;
571            }
572        }
573    }
574    any.then(|| ImageMask::Alpha {
575        width,
576        height,
577        alpha: alpha.into(),
578        stencil: false,
579    })
580}
581
582/// Whether the samples are read straight out of the stream, with no image
583/// codec standing between it and the scanline.
584///
585/// This is the precondition of the zeroed-output arm: a row past the end of
586/// the data reads as all zeros only when the samples came straight from the
587/// stream. A codec — JBIG2, JPX, DCT, CCITT, or a Flate/RunLength predictor
588/// built as a scanline decoder — always hands back a full row, so the empty
589/// case never arises there and every row is decoded normally. Only a stream
590/// read directly can run out. Applying the zeroed arm to a codec's output
591/// instead makes a truncated JBIG2 mask stop inverting partway down, which is
592/// what `bug_674771.in` showed.
593fn reads_the_stream_directly(info: &ImageDict) -> bool {
594    !matches!(
595        info.last_filter,
596        Some(Filter::Jbig2 | Filter::Jpx | Filter::Dct | Filter::CcittFax)
597    )
598}
599
600/// A stencil mask: one bit per pixel, inverted when the decode is the
601/// default.
602fn decode_stencil<R: Resolve>(
603    stream: &Stream,
604    info: &ImageDict,
605    r: &R,
606    limits: &Limits,
607    diags: &mut Diagnostics,
608) -> Result<ImageData, Error> {
609    let total = info.total_bytes().ok_or(Error::ImageTooLarge)?;
610    let decoded = decode_chain(stream, total, r, limits, diags);
611    let row_bytes = info.pitch().ok_or(Error::ImageTooLarge)?;
612
613    // A stencil is still allowed to be JBIG2-coded, and then the codestream —
614    // not the stream's own bytes — is what carries the bits. Missing that
615    // makes a compressed codestream get read as if it were already one bit per
616    // pixel: `bug_527174.pdf`'s single data byte `0x30` unpacked to a set bit
617    // and painted a solid black square where the codec should have refused the
618    // image outright. Nothing else reaches this rung with an image codec in
619    // front, because every other one needs a colour space and a colour space
620    // means this is not a stencil.
621    if info.last_filter == Some(Filter::Jbig2) {
622        return stencil_from_jbig2(stream, info, &decoded.data, r, limits, diags);
623    }
624
625    // A fax-coded stencil is the same story: the codestream, not the stream's
626    // own bytes, carries the bits. Its samples arrive in the ordinary sense —
627    // a set bit is white — so from here they take the ordinary decode, which
628    // is the inversion below.
629    let ccitt = if info.last_filter == Some(Filter::CcittFax) {
630        Some(ccitt_samples(info, &decoded.data, r, limits, diags)?)
631    } else {
632        None
633    };
634    let samples = ccitt.as_deref().unwrap_or(&decoded.data);
635
636    let mut bits = vec![0u8; total];
637    let mut padded = false;
638    let raw = reads_the_stream_directly(info);
639    for y in 0..info.height {
640        let (mut line, availability) = scanline::scanline(samples, y, row_bytes);
641        padded |= availability != scanline::Availability::Whole;
642        // The default decode **inverts**; `/Decode [1 0]` copies verbatim —
643        // except on a row the stream never reached, which skips the decode
644        // altogether and comes back zero. See [`reads_the_stream_directly`]
645        // for why that only applies when there is no image codec in front.
646        if info.default_decode && !(raw && availability == scanline::Availability::Absent) {
647            scanline::invert_line(&mut line);
648        }
649        let start = usize::try_from(y).unwrap_or(0).saturating_mul(row_bytes);
650        if let Some(dest) = bits.get_mut(start..start + row_bytes) {
651            dest.copy_from_slice(&line);
652        }
653    }
654    if padded {
655        diags.record(Severity::Recovered, DiagKind::ImageStreamTruncated, None);
656    }
657    Ok(ImageData {
658        width: info.width,
659        height: info.height,
660        samples: Samples::Whole(Pixels::Stencil(BitImage {
661            width: info.width,
662            height: info.height,
663            row_bytes,
664            bits,
665        })),
666        mask: None,
667        matte: None,
668        interpolate: stream.dict.bool(names::INTERPOLATE).unwrap_or(false),
669    })
670}
671
672#[cfg(feature = "ccitt")]
673/// Decode a `/CCITTFaxDecode` image into the sample buffer the rest of the
674/// image path expects.
675///
676/// Two conventions have to line up, and only one of them needs work.
677///
678/// The **bit sense already matches**: the fax decoder fills a row with white
679/// and clears bits for black, and `/BlackIs1` inverts — which is exactly what
680/// a one-bit `/DeviceGray` sample means, so the bits are the samples with no
681/// translation. (This is the same pairing the JBIG2 *stencil* rung relies on,
682/// and the opposite of the JBIG2 *sample* rung, which inverts because JBIG2
683/// sets a bit for black.)
684///
685/// The **row stride does not**. A fax row is padded to four bytes, because
686/// that is the decoder's buffer shape; every consumer here reads rows at
687/// [`ImageDict::pitch`], which is `width.div_ceil(8)`. For any width that is
688/// not a multiple of 32 the two differ, and reading the wide buffer at the
689/// narrow stride shears the image progressively — each row starting a few
690/// pixels further into the previous one. Repacking is what this function is
691/// mostly for.
692fn ccitt_samples<R: Resolve>(
693    info: &ImageDict,
694    data: &[u8],
695    r: &R,
696    limits: &Limits,
697    diags: &mut Diagnostics,
698) -> Result<Vec<u8>, Error> {
699    let params = CcittParams::from_dict(&info.params, r);
700    let image =
701        decode_ccitt(data, params, info.width, info.height, limits, diags).map_err(|_| {
702            diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
703            Error::ImageUndecodable {
704                what: "CCITT fax data would not decode",
705            }
706        })?;
707    let pitch = info.pitch().ok_or(Error::ImageTooLarge)?;
708    let total = info.total_bytes().ok_or(Error::ImageTooLarge)?;
709    // The repacked buffer is a second image the size of the first, and
710    // `/Width` and `/Height` are the stream's to declare. The same budget that
711    // bounds the decoder's buffer bounds this one.
712    if total > limits.max_decoded_stream_len {
713        return Err(Error::ImageTooLarge);
714    }
715    // White, so a row the decoder never produced — a stream that stops short of
716    // the declared height — reads as blank rather than as black. The decoder
717    // pre-fills its own rows the same way.
718    let mut out = vec![0xffu8; total];
719    for y in 0..info.height {
720        let src = usize::try_from(y)
721            .ok()
722            .and_then(|y| y.checked_mul(image.row_bytes));
723        let dest = usize::try_from(y).ok().and_then(|y| y.checked_mul(pitch));
724        let (Some(src), Some(dest)) = (src, dest) else {
725            continue;
726        };
727        let copy = pitch.min(image.row_bytes);
728        let (Some(from), Some(to)) = (
729            image.bits.get(src..src.saturating_add(copy)),
730            out.get_mut(dest..dest.saturating_add(copy)),
731        ) else {
732            continue;
733        };
734        to.copy_from_slice(from);
735    }
736    Ok(out)
737}
738#[cfg(not(feature = "ccitt"))]
739fn ccitt_samples<R: Resolve>(
740    info: &ImageDict,
741    data: &[u8],
742    r: &R,
743    limits: &Limits,
744    diags: &mut Diagnostics,
745) -> Result<Vec<u8>, Error> {
746    let _ = (info, data, r, limits);
747    diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
748    Err(Error::ImageUndecodable {
749        what: "this build has no CCITT fax decoder (feature `ccitt`)",
750    })
751}
752
753#[cfg(feature = "jbig2")]
754/// A stencil whose bits come out of a JBIG2 codestream.
755///
756/// A codestream that will not decode is **fatal to the image**, not something
757/// to paint around: PDFium tears the half-built bitmap down and reports the
758/// load as failed, so nothing at all is drawn. That is why a `/JBIG2Globals`
759/// stream of binary garbage makes a whole image vanish even though the image
760/// itself is one pixel — the globals are parsed first and their failure is the
761/// image's failure. Returning `Err` here reaches the same place: the builder
762/// drops the object.
763///
764/// The bit sense already matches. JBIG2 sets a bit for a black pixel and a
765/// stencil paints where a bit is set, which is exactly the pairing the default
766/// `/Decode` asks for; `/Decode [1 0]` reverses the meaning of the samples and
767/// so flips every bit.
768fn stencil_from_jbig2<R: Resolve>(
769    stream: &Stream,
770    info: &ImageDict,
771    data: &[u8],
772    r: &R,
773    limits: &Limits,
774    diags: &mut Diagnostics,
775) -> Result<ImageData, Error> {
776    let globals = info
777        .params
778        .stream(names::JBIG2_GLOBALS, r)
779        // Absent or unfetchable globals are silently tolerated; globals that
780        // are present but will not parse are not, and fail inside the codec.
781        .map(|s| decode_chain(&s, 0, r, limits, diags).data);
782    let mut image = decode_jbig2(globals.as_deref(), data, info.width, info.height, limits)
783        .inspect_err(|_| {
784            diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
785        })?;
786    if !info.default_decode {
787        for byte in &mut image.bits {
788            *byte = !*byte;
789        }
790    }
791    Ok(ImageData {
792        width: info.width,
793        height: info.height,
794        samples: Samples::Whole(Pixels::Stencil(image)),
795        mask: None,
796        matte: None,
797        interpolate: stream.dict.bool(names::INTERPOLATE).unwrap_or(false),
798    })
799}
800#[cfg(not(feature = "jbig2"))]
801fn stencil_from_jbig2<R: Resolve>(
802    stream: &Stream,
803    info: &ImageDict,
804    data: &[u8],
805    r: &R,
806    limits: &Limits,
807    diags: &mut Diagnostics,
808) -> Result<ImageData, Error> {
809    let _ = (stream, info, data, r, limits);
810    diags.record(Severity::Suspicious, DiagKind::ImageDecodeFailed, None);
811    Err(Error::ImageUndecodable {
812        what: "this build has no JBIG2 decoder (feature `jbig2`)",
813    })
814}
815
816/// The colour space, consulting form resources only for an inline image.
817fn resolve_space<R: Resolve>(
818    dict: &Dict,
819    form_resources: Option<&Dict>,
820    page_resources: Option<&Dict>,
821    r: &R,
822    functions: &mut FunctionCache,
823    limits: &Limits,
824    diags: &mut Diagnostics,
825) -> Option<ColorSpace> {
826    let cs_obj = dict.raw(names::COLOR_SPACE)?;
827    // Form resources first when there are any, then the page's.
828    form_resources
829        .and_then(|res| {
830            crate::color::load_colorspace(cs_obj, Some(res), r, functions, limits, diags)
831        })
832        .or_else(|| {
833            crate::color::load_colorspace(cs_obj, page_resources, r, functions, limits, diags)
834        })
835}
836
837/// The sample geometry [`unpack`] derives once and its helpers re-read, kept
838/// together so a helper takes one argument rather than five positional
839/// `usize`s that are trivial to transpose.
840struct SampleLayout {
841    /// Bytes per source row.
842    pitch: usize,
843    /// Samples across.
844    pixels_per_row: usize,
845    /// Rows down.
846    rows: usize,
847    /// `pixels_per_row * rows`, already checked for overflow.
848    total_pixels: usize,
849    /// The largest value the sample depth can express.
850    max_raw: u32,
851}
852
853/// Read a one-component image's raw samples into one byte each.
854///
855/// `remap` is the `/Decode` mapping when the sample *is* the palette index
856/// and the array therefore remaps the index itself — which is the `Indexed`
857/// case — and `None` when the mapping belongs in the palette instead, which
858/// is [`tint_palette`]'s.
859///
860/// An absent row is left at zero: PDFium returns a zeroed *output* buffer
861/// without ever reaching the decode, so the index stays zero whatever
862/// `/Decode` maps a zero sample to. See [`scanline::Availability`].
863fn scan_indices(
864    info: &ImageDict,
865    data: &[u8],
866    remap: Option<&DecodeMap>,
867    layout: &SampleLayout,
868    diags: &mut Diagnostics,
869) -> Box<[u8]> {
870    let mut indices = vec![0u8; layout.total_pixels];
871    let mut padded = false;
872    for y in 0..layout.rows {
873        let (line, availability) =
874            scanline::scanline(data, u32::try_from(y).unwrap_or(0), layout.pitch);
875        padded |= availability != scanline::Availability::Whole;
876        if availability == scanline::Availability::Absent {
877            continue;
878        }
879        for x in 0..layout.pixels_per_row {
880            let raw = scanline::get_bits(&line, x * info.bpc as usize, info.bpc);
881            let index = match remap {
882                #[expect(
883                    clippy::cast_possible_truncation,
884                    clippy::cast_sign_loss,
885                    reason = "the clamp bounds the value to a palette index"
886                )]
887                Some(decode) => decode.apply(0, f64_to_f32(raw)).clamp(0.0, 255.0) as u8,
888                None => u8::try_from(raw.min(255)).unwrap_or(u8::MAX),
889            };
890            if let Some(slot) = indices.get_mut(y * layout.pixels_per_row + x) {
891                *slot = index;
892            }
893        }
894    }
895    if padded {
896        diags.record(Severity::Recovered, DiagKind::ImageStreamTruncated, None);
897    }
898    indices.into()
899}
900
901/// Resolve a one-component tint image into a palette and its indices.
902///
903/// For the families that have no device reading the tint transform runs once
904/// per distinct
905/// sample value rather than once per pixel, which is both exact — a sample of
906/// at most eight bits has at most 256 values — and the shape
907/// `pdfrum_render::image::to_pixmap` already has a fast path for.
908fn tint_palette(
909    info: &ImageDict,
910    space: &ColorSpace,
911    data: &[u8],
912    decode: &DecodeMap,
913    layout: &SampleLayout,
914    diags: &mut Diagnostics,
915) -> Pixels {
916    // The `/Decode` mapping belongs in the palette rather than on the index,
917    // because here the sample is a *tint* the transform consumes rather than
918    // a position in a table.
919    let indices = scan_indices(info, data, None, layout, diags);
920    // The palette spans every value the sample depth can express, with the
921    // mapping folded into each entry exactly as `LoadPalette` folds
922    // `decode_min_ + decode_step_ * i` into its own.
923    let entries = usize::try_from(layout.max_raw).unwrap_or(255).min(255) + 1;
924    let palette = (0..entries)
925        .map(|i| {
926            #[expect(
927                clippy::cast_precision_loss,
928                reason = "an index of at most 255 is exact in f32"
929            )]
930            let value = decode.apply(0, i as f32);
931            space.to_rgb(&[value])
932        })
933        .collect();
934    Pixels::Indexed { indices, palette }
935}
936
937/// Resolve a multi-colorant tint image one pixel at a time.
938///
939/// Reached the way the oracle reaches its own equivalent: the default-decode
940/// shortcut keeps only `DeviceRGB`/`CalRGB` and hands every other family to
941/// the bulk translation, whose generic base is the scalar conversion per
942/// pixel. A `DeviceN` over more than one colorant has too wide a sample tuple
943/// to tabulate, so there is no palette to build.
944///
945/// The conversion itself is [`ColorSpace::translate_image_line`], which was
946/// ported whole and until now had no caller in the image build at all — only
947/// a panic test. That absent call site is the actual defect: the arithmetic
948/// was always here, `unpack` simply never asked for it.
949///
950/// `samples` holds the `/Decode`-mapped components as bytes in the space's
951/// own component order. The port writes **B, G, R** because that is the
952/// device order PDFium's scanline is in; `Pixels::Rgb8` wants R, G, B, so the
953/// triples are swapped on the way out rather than by giving the port a second
954/// byte order to maintain.
955fn tint_per_pixel(
956    space: &ColorSpace,
957    samples: &[u8],
958    total_pixels: usize,
959) -> Result<Pixels, Error> {
960    let mut bgr = vec![0u8; total_pixels.checked_mul(3).ok_or(Error::ImageTooLarge)?];
961    space.translate_image_line(&mut bgr, samples, total_pixels, false);
962    for px in bgr.as_chunks_mut::<3>().0 {
963        px.swap(0, 2);
964    }
965    Ok(Pixels::Rgb8(bgr.into()))
966}
967
968/// Prepare raw or losslessly-filtered samples for the row pipeline.
969///
970/// The two families that cannot be walked lazily resolve here and return
971/// [`Samples::Whole`]: an indexed or single-colorant tint image, whose samples
972/// are *positions in a palette* the caller must be handed with them, and a
973/// multi-colorant `DeviceN`, whose tint transform is per pixel and has no
974/// table. Everything else -- which is the common case, and the whole of the
975/// guide -- becomes a [`Packed`] the row stages widen as they walk it.
976fn unpack(
977    info: &ImageDict,
978    space: Option<&ColorSpace>,
979    data: &[u8],
980    diags: &mut Diagnostics,
981) -> Result<Samples, Error> {
982    let space = space.ok_or(Error::ImageNoColorSpace)?;
983    let components = usize::try_from(info.components).unwrap_or(0);
984    if components == 0 || info.bpc == 0 {
985        return Err(Error::ImageUndecodable {
986            what: "zero components or bit depth",
987        });
988    }
989    let pitch = info.pitch().ok_or(Error::ImageTooLarge)?;
990    let pixels_per_row = usize::try_from(info.width).unwrap_or(0);
991    let rows = usize::try_from(info.height).unwrap_or(0);
992    let total_pixels = pixels_per_row
993        .checked_mul(rows)
994        .ok_or(Error::ImageTooLarge)?;
995
996    let decode = DecodeMap::new(Some(space), components, info.bpc, info.decode.as_ref());
997    let max_raw = if info.bpc >= 32 {
998        u32::MAX
999    } else {
1000        (1u32 << info.bpc) - 1
1001    };
1002
1003    let layout = SampleLayout {
1004        pitch,
1005        pixels_per_row,
1006        rows,
1007        total_pixels,
1008        max_raw,
1009    };
1010
1011    // An indexed image keeps its indices and a resolved palette, which is
1012    // what a renderer needs to resample it without blending indices. An
1013    // `/Decode` on one remaps the **index** itself, so it is applied during
1014    // the scan and the palette is the space's own.
1015    if let ColorSpace::Indexed(indexed) = space {
1016        let indices = scan_indices(info, data, Some(&decode), &layout, diags);
1017        let palette = (0..=indexed.max_index)
1018            .map(|i| space.to_rgb(&[f32::from(i)]))
1019            .collect();
1020        return Ok(Samples::Whole(Pixels::Indexed { indices, palette }));
1021    }
1022
1023    // A `Separation` or `DeviceN` sample is a **tint**, not a colour, so it
1024    // has to be run through the tint transform before it means anything.
1025    // Widening it to a byte and handing it to the device reading of the
1026    // component count paints the tint itself — one colorant becomes a grey
1027    // level, four become a CMYK tuple. See
1028    // [`ColorSpace::needs_image_conversion`] for why these two families and
1029    // no others.
1030    //
1031    // PDFium reaches the conversion from two directions and both end at
1032    // `GetRGB`. When `bpc_ * components_ <= 8` — which covers every eight-bit
1033    // single-component image — `CPDF_DIB::LoadPalette`
1034    // (`cpdf_dib.cpp:894-979`) precomputes `GetRGB` over all `1 << bits`
1035    // possible sample values and the image becomes a palette lookup.
1036    // Anything wider takes `TranslateScanline24bpp` (`:1007-1054`), whose
1037    // default-decode shortcut (`:1056-1075`) hands every non-RGB family to
1038    // `TranslateImageLine`; the generic base (`cpdf_colorspace.cpp:636-660`)
1039    // is `GetRGB` per pixel again.
1040    //
1041    // For a single component both collapse to the same thing here: resolve
1042    // the colour once per distinct sample value into a palette, which is
1043    // exact (there are at most 256 of them) and is the shape the renderer's
1044    // indexed fast path already consumes. `corpus/fx/other/1.pdf` is the
1045    // fixture — a 1x1 `/Separation` image whose lone `0xC6` sample is a 0.776
1046    // tint of PANTONE 327 CV, teal `(0, 182, 162)` through the tint transform
1047    // and grey `(198, 198, 198)` without it.
1048    if components == 1 && space.needs_image_conversion() {
1049        return Ok(Samples::Whole(tint_palette(
1050            info, space, data, &decode, &layout, diags,
1051        )));
1052    }
1053
1054    // A multi-colorant `DeviceN` cannot be tabulated -- its sample tuple is
1055    // too wide -- so it takes `TranslateScanline24bpp`'s own shape instead,
1056    // which is per pixel and therefore eager.
1057    if space.needs_image_conversion() {
1058        let out = widen_whole(info, data, &decode, &layout, diags)?;
1059        return Ok(Samples::Whole(tint_per_pixel(space, &out, total_pixels)?));
1060    }
1061
1062    // Everything else stays packed. The `/Decode` mapping is folded into the
1063    // table `Packed` builds, and the widening itself happens one row at a time
1064    // inside [`Unpacked`] as the pipeline pulls.
1065    let depth = Depth::new(info.bpc).ok_or(Error::ImageUndecodable {
1066        what: "a bit depth that is not 1, 2, 4, 8 or 16",
1067    })?;
1068    let packed = Packed::with_map(
1069        data.into(),
1070        depth,
1071        components,
1072        pitch,
1073        info.width,
1074        info.height,
1075        &decode,
1076    );
1077    // The eager pass recorded this while it walked; a lazy one answers the
1078    // same question from the stream length, which is the same answer.
1079    if packed.truncated() {
1080        diags.record(Severity::Recovered, DiagKind::ImageStreamTruncated, None);
1081    }
1082    Ok(Samples::Packed(packed))
1083}
1084
1085/// Widen every sample to a byte, over the whole image.
1086///
1087/// The one caller left is the multi-colorant tint path, whose transform is per
1088/// pixel over a component tuple and so has to see the whole thing at once.
1089fn widen_whole(
1090    info: &ImageDict,
1091    data: &[u8],
1092    decode: &DecodeMap,
1093    layout: &SampleLayout,
1094    diags: &mut Diagnostics,
1095) -> Result<Vec<u8>, Error> {
1096    let components = usize::try_from(info.components).unwrap_or(0);
1097    let mut out = vec![
1098        0u8;
1099        layout
1100            .total_pixels
1101            .checked_mul(components)
1102            .ok_or(Error::ImageTooLarge)?
1103    ];
1104    let mut padded = false;
1105    for y in 0..layout.rows {
1106        let (line, availability) =
1107            scanline::scanline(data, u32::try_from(y).unwrap_or(0), layout.pitch);
1108        padded |= availability != scanline::Availability::Whole;
1109        // An absent row never reaches `TranslateScanline24bpp`: PDFium returns
1110        // a zeroed *output* buffer, so the pixels are literal black rather
1111        // than whatever `/Decode` maps a zero sample to. See `Availability`.
1112        if availability == scanline::Availability::Absent {
1113            continue;
1114        }
1115        for x in 0..layout.pixels_per_row {
1116            for c in 0..components {
1117                let bit_pos = (x * components + c) * info.bpc as usize;
1118                let raw = scanline::get_bits(&line, bit_pos, info.bpc);
1119                let value = decode.apply(c, f64_to_f32(raw));
1120                #[expect(
1121                    clippy::cast_possible_truncation,
1122                    clippy::cast_sign_loss,
1123                    reason = "the clamp bounds the product to 0..=255"
1124                )]
1125                let byte = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
1126                if let Some(slot) = out.get_mut((y * layout.pixels_per_row + x) * components + c) {
1127                    *slot = byte;
1128                }
1129            }
1130        }
1131    }
1132    if padded {
1133        diags.record(Severity::Recovered, DiagKind::ImageStreamTruncated, None);
1134    }
1135    Ok(out)
1136}
1137
1138/// Apply a non-default `/Decode` to a codec's eight-bit output, in place.
1139///
1140/// `TranslateScanline24bpp` runs on the *decoder's* scanline, not only on raw
1141/// samples, so a `/Decode` array reaches a DCT or JPEG 2000 image exactly as
1142/// it reaches a Flate one. The codecs set `bpc_ = 8` before the scanline is
1143/// read, so the mapping is always over `0..=255` here whatever the dictionary
1144/// declared. `bug_1646` and `bug_718762` are the fixtures: a CMYK JPEG
1145/// carrying the Adobe inversion as `/Decode [1 0 1 0 1 0 1 0]`, which without
1146/// this reaches the page as its own negative.
1147///
1148/// The default mapping is a no-op by construction, so it is skipped rather
1149/// than run — which also keeps an `Indexed` codec output (whose indices this
1150/// mapping does not describe) untouched.
1151///
1152/// # It is a table, because the mapping has 256 possible answers per component
1153///
1154/// The value written for a byte depends on the component index and the byte,
1155/// and on nothing else — so the whole mapping is `components * 256` bytes,
1156/// at most a kibibyte. Evaluating it per sample instead costs an integer
1157/// division (`i % components`), two bounds-checked slice reads, a float
1158/// multiply-add, a clamp, a `round` and a cast, on **every byte of the image**.
1159///
1160/// On `image_bug_718762` — a 5000x5000 CMYK JPEG whose `/Decode
1161/// [1 0 1 0 1 0 1 0]` is the Adobe inversion written out, so the
1162/// `default_decode` short circuit above does not fire — that loop ran over
1163/// 100,000,000 bytes at 2.9 ns each and was **83% of the whole image decode**,
1164/// against 17% for `zune_jpeg` itself. The table
1165/// is built from the same [`DecodeMap::apply`] and the same rounding, so every
1166/// output byte is identical by construction; only the number of times the
1167/// arithmetic runs changes.
1168fn apply_codec_decode(
1169    data: &mut [u8],
1170    space: Option<&ColorSpace>,
1171    components: u8,
1172    info: &ImageDict,
1173) {
1174    let components = usize::from(components);
1175    if components == 0 || info.default_decode {
1176        return;
1177    }
1178    let Some(space) = space else { return };
1179    // An indexed space maps indices rather than colour components, and the
1180    // codec paths that produce indices resolve them through the palette.
1181    if matches!(space, ColorSpace::Indexed(_)) {
1182        return;
1183    }
1184    let decode = DecodeMap::new(Some(space), components, 8, info.decode.as_ref());
1185    if decode.default {
1186        return;
1187    }
1188    let table = decode_table(&decode, components);
1189    // Chunked by component so the row index is a position in the chunk rather
1190    // than a division: `chunks_mut` gives back the whole trailing partial
1191    // chunk too, which is what keeps an image whose byte count is not a whole
1192    // number of pixels mapping exactly as the per-sample loop did.
1193    for chunk in data.chunks_mut(components) {
1194        for (component, sample) in chunk.iter_mut().enumerate() {
1195            if let Some(row) = table.get(component)
1196                && let Some(mapped) = row.get(usize::from(*sample))
1197            {
1198                *sample = *mapped;
1199            }
1200        }
1201    }
1202}
1203
1204/// Every answer [`DecodeMap::apply`] can give, one row of 256 per component.
1205///
1206/// The rounding is the per-sample loop's, verbatim: **`round`, not the
1207/// truncation the raw-sample path uses.** PDFium carries these values as floats
1208/// all the way into the colour conversion and only truncates the *converted*
1209/// byte; we have to land them back in a byte here, so the encode has to be the
1210/// one that makes the round trip exact. Truncating instead loses a count on the
1211/// commonest case of all — the `[1 0]` inversion, where `1 - 253/255` lands a
1212/// hair under `2/255` and would come back as 1.
1213fn decode_table(decode: &DecodeMap, components: usize) -> Vec<[u8; 256]> {
1214    (0..components)
1215        .map(|component| {
1216            let mut row = [0u8; 256];
1217            for (raw, slot) in row.iter_mut().enumerate() {
1218                #[expect(
1219                    clippy::cast_precision_loss,
1220                    reason = "a table index below 256 is exact in f32"
1221                )]
1222                let value = decode.apply(component, raw as f32);
1223                #[expect(
1224                    clippy::cast_possible_truncation,
1225                    clippy::cast_sign_loss,
1226                    reason = "the clamp bounds the product to 0..=255"
1227                )]
1228                let byte = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
1229                *slot = byte;
1230            }
1231            row
1232        })
1233        .collect()
1234}
1235
1236/// A raw sample as a float, at the precision the decode arithmetic uses.
1237#[expect(
1238    clippy::cast_precision_loss,
1239    reason = "raw samples cap at sixteen bits, exact in f32"
1240)]
1241fn f64_to_f32(raw: u32) -> f32 {
1242    raw as f32
1243}
1244
1245/// Load `/SMask`, then `/Mask`, then the colour key — in that order, and
1246/// stopping at the first that produces something.
1247#[expect(
1248    clippy::too_many_arguments,
1249    reason = "loading a mask recursively needs the same context the base image did"
1250)]
1251fn load_mask<R: Resolve>(
1252    dict: &Dict,
1253    info: &ImageDict,
1254    space: Option<&ColorSpace>,
1255    jpx_alpha: Option<Vec<u8>>,
1256    r: &R,
1257    functions: &mut FunctionCache,
1258    limits: &Limits,
1259    diags: &mut Diagnostics,
1260) -> Option<ImageMask> {
1261    // A JPX image's captured alpha is already a mask.
1262    if let Some(alpha) = jpx_alpha {
1263        return Some(ImageMask::Alpha {
1264            width: info.width,
1265            height: info.height,
1266            alpha: alpha.into(),
1267            stencil: false,
1268        });
1269    }
1270    // `/SMask` first, and its presence means `/Mask` is never consulted.
1271    if let Some(smask) = dict.stream(names::SMASK, r) {
1272        return load_mask_image(&smask, false, r, functions, limits, diags);
1273    }
1274    match dict.get(names::MASK, r).as_deref() {
1275        // A `/Mask` stream is a stencil, whose sense is inverted.
1276        Some(Object::Stream(mask_stream)) => {
1277            load_mask_image(mask_stream, true, r, functions, limits, diags)
1278        }
1279        // A `/Mask` array is a colour key.
1280        Some(Object::Array(array)) => {
1281            let components = usize::try_from(info.components).unwrap_or(0);
1282            if !ColorKey::is_complete(array, components) {
1283                diags.record(Severity::Suspicious, DiagKind::ColorKeyArrayShort, None);
1284            }
1285            let max_raw = if info.bpc >= 32 {
1286                u32::MAX
1287            } else {
1288                (1u32 << info.bpc.max(1)) - 1
1289            };
1290            let _ = space;
1291            Some(ImageMask::ColorKey(ColorKey::from_array(
1292                array, components, max_raw,
1293            )))
1294        }
1295        _ => None,
1296    }
1297}
1298
1299/// Decode a mask image.
1300///
1301/// A mask is loaded **at full resolution, with no resources, and with no mask
1302/// of its own** — so a four-hundred-pixel mask stays four hundred pixels even
1303/// beside a fifty-pixel base image, and a mask can never carry a mask.
1304///
1305/// A failure here **drops the mask and keeps the base image**; it never fails
1306/// the image.
1307fn load_mask_image<R: Resolve>(
1308    stream: &Stream,
1309    stencil: bool,
1310    r: &R,
1311    functions: &mut FunctionCache,
1312    limits: &Limits,
1313    diags: &mut Diagnostics,
1314) -> Option<ImageMask> {
1315    let decoded = decode_image(
1316        stream,
1317        None,
1318        None,
1319        // Never resolution-reduced.
1320        RequestedSize::Full,
1321        r,
1322        functions,
1323        limits,
1324        diags,
1325    );
1326    let Ok(image) = decoded else {
1327        diags.record(Severity::Recovered, DiagKind::MaskDropped, None);
1328        return None;
1329    };
1330    let Some(alpha) = mask_plane(&image.samples, image.width, image.height) else {
1331        diags.record(Severity::Recovered, DiagKind::MaskDropped, None);
1332        return None;
1333    };
1334    Some(ImageMask::Alpha {
1335        width: image.width,
1336        height: image.height,
1337        alpha,
1338        stencil,
1339    })
1340}
1341
1342/// A decoded mask image's samples as the one-byte-per-pixel coverage plane an
1343/// [`ImageMask::Alpha`] carries.
1344///
1345/// A soft mask's alpha is its luminosity and a stencil's is its coverage;
1346/// both are the **first byte of the converted sample**, so this is one walk
1347/// of the [`Converted`] row pipeline keeping the red channel.
1348///
1349/// This is the one call site in the *build* that walks a whole image, and on
1350/// a document of soft-masked thumbnails it is the larger of the two:
1351/// `image_en_fqa` builds 29.8 million mask samples per page and never
1352/// converts more than four of the base image's.
1353///
1354/// A codec's `Gray8` keeps a direct arm because its sample already *is* the
1355/// alpha — the row pipeline would widen each byte to RGBA only for this to
1356/// take the first channel back. Every other kind, packed samples included,
1357/// goes through the rows, which is the same answer:
1358/// `the_mask_planes_fast_arms_are_the_general_one` pins the equality.
1359///
1360/// `None` when the dimensions overflow a `usize`, the area is above
1361/// [`MAX_IMAGE_PIXELS`], or the allocator will not meet the buffer.
1362fn mask_plane(samples: &Samples, width: u32, height: u32) -> Option<Box<[u8]>> {
1363    // Same predicate `to_pixmap` and the reduction pre-pass use. A mask is
1364    // never resolution-reduced, so without it the dictionary's per-axis
1365    // limit of 131071 would still ask for 17 GB. `try_reserve_exact` is not
1366    // a refusal on an overcommit host: Linux lets that reserve succeed, and
1367    // the `resize` below then zeros 17 GB.
1368    if !image_area_is_workable(width, height) {
1369        return None;
1370    }
1371    let len = usize::try_from(width)
1372        .ok()?
1373        .checked_mul(usize::try_from(height).ok()?)?;
1374    // Second line, for a size inside the cap that this allocator still cannot
1375    // meet. `handle_alloc_error` would abort with no unwind; `None` here is
1376    // the mask being dropped, which is what this function's contract already
1377    // says a failure means.
1378    let mut alpha = Vec::new();
1379    alpha.try_reserve_exact(len).ok()?;
1380    if let Samples::Whole(Pixels::Gray8(data)) = samples {
1381        alpha.extend(data.iter().take(len).copied());
1382    } else {
1383        let palette = match samples {
1384            Samples::Whole(Pixels::Indexed { palette, .. }) => Some(rows::Palette::new(palette)),
1385            _ => None,
1386        };
1387        let mut converted =
1388            rows::Converted::new(rows::Source::new(samples, width, height), palette);
1389        while let Some(row) = rows::Rows::next(&mut converted) {
1390            alpha.extend(row.pixels().iter().map(|px| px.0[0]));
1391        }
1392    }
1393    // A source plane shorter than the image it describes reads as fully
1394    // transparent past its end, which is what the zero-filled `Vec` the old
1395    // walk wrote into did for exactly those samples.
1396    alpha.resize(len, 0);
1397    Some(alpha.into())
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402    // Test fixtures quote the oracle's own vectors, compare floats exactly
1403    // where the behaviour being pinned is exact, and index arrays whose
1404    // length the fixture itself fixes.
1405    #![allow(
1406        clippy::unreadable_literal,
1407        clippy::float_cmp,
1408        clippy::indexing_slicing,
1409        clippy::cast_precision_loss,
1410        clippy::cast_possible_truncation,
1411        clippy::cast_sign_loss,
1412        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
1413    )]
1414
1415    use super::{ImageData, Pixels, RequestedSize, Samples, decode_image};
1416    use crate::color::Rgb;
1417    use crate::function::FunctionCache;
1418    use crate::image::BitImage;
1419    use pdfrum_common::{DiagKind, Diagnostics, Limits};
1420    use pdfrum_object::{Array, ByteSpan, Dict, Name, NoResolve, Object, Stream};
1421
1422    fn stream(pairs: Vec<(Name, Object)>, data: &[u8]) -> Stream {
1423        Stream::new(Dict::from_pairs(pairs), ByteSpan::from(data.to_vec()))
1424    }
1425
1426    fn decode(s: &Stream) -> Result<ImageData, crate::Error> {
1427        let mut funcs = FunctionCache::new();
1428        let mut diags = Diagnostics::default();
1429        decode_image(
1430            s,
1431            None,
1432            None,
1433            RequestedSize::Full,
1434            &NoResolve,
1435            &mut funcs,
1436            &Limits::default(),
1437            &mut diags,
1438        )
1439    }
1440
1441    /// One pixel's converted colour, through the row pipeline.
1442    ///
1443    /// The pipeline is row-at-a-time by design, so a test that wants a single
1444    /// pixel walks to its row and indexes it. Tests are the only caller that
1445    /// ever wants one pixel — the render path wants all of them, in order.
1446    fn converted_row(samples: &Samples, width: u32, y: u32) -> Vec<[u8; 3]> {
1447        let palette = samples.palette().map(super::rows::Palette::new);
1448        // Only the wanted row is converted. Walking down to row `y` from the
1449        // top would be quadratic in `y`, and these tests reach for row 9999 to
1450        // check the out-of-range fallback.
1451        let mut converted =
1452            super::rows::Converted::new(super::rows::Source::at_row(samples, width, y), palette);
1453        super::rows::Rows::next(&mut converted)
1454            .map(|row| {
1455                row.pixels()
1456                    .iter()
1457                    .map(|px| [px.0[0], px.0[1], px.0[2]])
1458                    .collect()
1459            })
1460            .unwrap_or_default()
1461    }
1462
1463    /// One pixel's converted colour, for a test that wants a single one.
1464    ///
1465    /// A caller comparing a whole row should take [`converted_row`] once
1466    /// instead: this rebuilds the row buffer on every call, which is the right
1467    /// trade for a handful of pixels and the wrong one for thousands.
1468    fn sample_at(samples: &Samples, x: u32, y: u32, width: u32) -> [u8; 3] {
1469        converted_row(samples, width, y)
1470            .get(x as usize)
1471            .copied()
1472            .unwrap_or([0, 0, 0])
1473    }
1474
1475    #[test]
1476    fn a_colour_key_becomes_an_alpha_plane_on_the_raw_samples() {
1477        // `/Mask [0 0]` over a 2x2 eight-bit grey image: the two zero samples
1478        // go transparent and the rest stay opaque. The predicate runs on the
1479        // *raw* values, so it is the byte 0 that matches, not the colour.
1480        let mut mask = Array::default();
1481        mask.push(Object::Int(0));
1482        mask.push(Object::Int(0));
1483        let s = stream(
1484            vec![
1485                (Name::from("Width"), Object::Int(2)),
1486                (Name::from("Height"), Object::Int(2)),
1487                (Name::from("BitsPerComponent"), Object::Int(8)),
1488                (
1489                    Name::from("ColorSpace"),
1490                    Object::Name(Name::from("DeviceGray")),
1491                ),
1492                (Name::from("Mask"), Object::Array(mask)),
1493            ],
1494            &[0, 200, 0, 255],
1495        );
1496        let image = decode(&s).expect("should decode");
1497        let Some(crate::image::ImageMask::Alpha { alpha, .. }) = image.mask else {
1498            panic!("expected a resolved alpha plane, got {:?}", image.mask);
1499        };
1500        assert_eq!(&*alpha, &[0u8, 255, 0, 255]);
1501    }
1502
1503    #[test]
1504    fn a_colour_key_that_matches_nothing_leaves_the_image_opaque() {
1505        // No pixel falls in the range, so there is no plane to carry — the
1506        // image is opaque and says so by having no mask at all.
1507        let mut mask = Array::default();
1508        mask.push(Object::Int(7));
1509        mask.push(Object::Int(9));
1510        let s = stream(
1511            vec![
1512                (Name::from("Width"), Object::Int(2)),
1513                (Name::from("Height"), Object::Int(1)),
1514                (Name::from("BitsPerComponent"), Object::Int(8)),
1515                (
1516                    Name::from("ColorSpace"),
1517                    Object::Name(Name::from("DeviceGray")),
1518                ),
1519                (Name::from("Mask"), Object::Array(mask)),
1520            ],
1521            &[0, 200],
1522        );
1523        assert!(decode(&s).expect("should decode").mask.is_none());
1524    }
1525
1526    #[test]
1527    fn a_row_past_the_end_of_the_stream_skips_the_decode_entirely() {
1528        // `bug_554151.in` in miniature: a `/Decode` that maps a zero sample
1529        // to full red, and a stream holding only the first of two rows.
1530        //
1531        // The first row decodes: `FF` at four bits is 15, and
1532        // `1 + (0 - 1) * 15/15` is 0, so it is black. The second row never
1533        // reaches the decode at all — `CPDF_DIB::GetScanline` hands back a
1534        // zeroed *output* buffer — so it is also black, and emphatically not
1535        // the red that decoding a zero sample would give.
1536        let mut decode_array = Array::default();
1537        decode_array.push(Object::Real(1.0));
1538        let s = stream(
1539            vec![
1540                (Name::from("Width"), Object::Int(2)),
1541                (Name::from("Height"), Object::Int(2)),
1542                (Name::from("BitsPerComponent"), Object::Int(4)),
1543                (
1544                    Name::from("ColorSpace"),
1545                    Object::Name(Name::from("DeviceRGB")),
1546                ),
1547                (Name::from("Decode"), Object::Array(decode_array)),
1548            ],
1549            // One row: two pixels of three four-bit components each.
1550            &[0xFF, 0xFF, 0xFF],
1551        );
1552        let image = decode(&s).expect("should decode");
1553        assert_eq!(
1554            image.samples.to_pixels(),
1555            Pixels::Rgb8(Box::from(&[0u8; 12][..])),
1556            "both rows are black; the absent one never reaches `/Decode`"
1557        );
1558    }
1559
1560    #[test]
1561    fn an_eight_bit_grayscale_image_round_trips() {
1562        let s = stream(
1563            vec![
1564                (Name::from("Width"), Object::Int(2)),
1565                (Name::from("Height"), Object::Int(2)),
1566                (Name::from("BitsPerComponent"), Object::Int(8)),
1567                (
1568                    Name::from("ColorSpace"),
1569                    Object::Name(Name::from("DeviceGray")),
1570                ),
1571            ],
1572            &[0, 85, 170, 255],
1573        );
1574        let image = decode(&s).expect("should decode");
1575        assert_eq!((image.width, image.height), (2, 2));
1576        assert_eq!(
1577            image.samples.to_pixels(),
1578            Pixels::Gray8(Box::from(&[0u8, 85, 170, 255][..]))
1579        );
1580        assert!(image.mask.is_none());
1581    }
1582
1583    /// The 94-byte JBIG2 codestream from `transfer_function.in`'s
1584    /// `/IM_1bpp`, a 400x400 image that decodes to solid black.
1585    const JBIG2_ALL_BLACK: [u8; 94] = [
1586        0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x13, 0x00, 0x00, 0x0d, 0xea,
1587        0x00, 0x00, 0x03, 0x53, 0x00, 0x00, 0x17, 0x11, 0x00, 0x00, 0x17, 0x11, 0x51, 0x00, 0x00,
1588        0x00, 0x00, 0x00, 0x01, 0x26, 0x00, 0x01, 0x00, 0x00, 0x00, 0x35, 0x00, 0x00, 0x0d, 0xea,
1589        0x00, 0x00, 0x03, 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03,
1590        0xff, 0xfd, 0xff, 0x02, 0xfe, 0xfe, 0xfe, 0xff, 0x7f, 0x86, 0x53, 0x0f, 0xb6, 0xc9, 0x22,
1591        0xcf, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f, 0xff, 0x7f,
1592        0xff, 0x7f, 0xff, 0xac,
1593    ];
1594
1595    #[test]
1596    fn a_jbig2_image_with_a_colour_space_is_a_picture_and_not_a_stencil() {
1597        // A JBIG2 codestream is bi-level, and it is tempting to conclude that
1598        // every JBIG2 image is a mask. It is not: this one declares
1599        // `/DeviceGray` and no `/ImageMask`, so it is an ordinary one-bit
1600        // picture and the sample convention is the PDF's — 0 is black.
1601        // Reading it as a stencil paints it in the fill colour wherever
1602        // JBIG2 said "black", which for an all-black image drawn on a light
1603        // page is a whole square of the wrong colour.
1604        let s = stream(
1605            vec![
1606                (Name::from("Width"), Object::Int(400)),
1607                (Name::from("Height"), Object::Int(400)),
1608                (Name::from("BitsPerComponent"), Object::Int(1)),
1609                (
1610                    Name::from("ColorSpace"),
1611                    Object::Name(Name::from("DeviceGray")),
1612                ),
1613                (
1614                    Name::from("Filter"),
1615                    Object::Name(Name::from("JBIG2Decode")),
1616                ),
1617            ],
1618            &JBIG2_ALL_BLACK,
1619        );
1620        let image = decode(&s).expect("should decode");
1621        assert_eq!((image.width, image.height), (400, 400));
1622        let pixels = image.samples.to_pixels();
1623        let Pixels::Gray8(gray) = &pixels else {
1624            panic!("expected grey samples, got {pixels:?}");
1625        };
1626        assert_eq!(gray.len(), 400 * 400);
1627        assert!(
1628            gray.iter().all(|&v| v == 0),
1629            "every sample is black: JBIG2's set bit inverts to sample 0"
1630        );
1631    }
1632
1633    /// A 69-byte embedded JBIG2 codestream: an 8x8 page whose every row is
1634    /// four white pixels then four black, so each row is `0b0000_1111`. Built
1635    /// from a page-information segment and one MMR-coded generic region, and
1636    /// small enough that a test can state the expected bits outright.
1637    const JBIG2_RIGHT_HALF_BLACK: [u8; 69] = [
1638        0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x13, 0x00, 0x00, 0x00, 0x08,
1639        0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
1640        0x00, 0x00, 0x00, 0x01, 0x26, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x08,
1641        0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x36,
1642        0xcd, 0xb3, 0x6c, 0xdb, 0x36, 0xcd, 0xb3, 0x6c, 0xdb,
1643    ];
1644
1645    /// A stencil dictionary over `data`, optionally with a `/Decode` array.
1646    fn jbig2_stencil(data: &[u8], decode: Option<[i64; 2]>) -> Stream {
1647        let mut pairs = vec![
1648            (Name::from("Width"), Object::Int(8)),
1649            (Name::from("Height"), Object::Int(8)),
1650            (Name::from("ImageMask"), Object::Bool(true)),
1651            (
1652                Name::from("Filter"),
1653                Object::Name(Name::from("JBIG2Decode")),
1654            ),
1655        ];
1656        if let Some([lo, hi]) = decode {
1657            pairs.push((
1658                Name::from("Decode"),
1659                Object::Array(Array::of([Object::Int(lo), Object::Int(hi)])),
1660            ));
1661        }
1662        stream(pairs, data)
1663    }
1664
1665    #[test]
1666    fn a_jbig2_stencil_takes_its_bits_from_the_codestream() {
1667        // Without a colour space the image is a stencil, but the bits still
1668        // come from the codec — reading the compressed bytes as if they were
1669        // already one bit per pixel paints noise.
1670        let image = decode(&jbig2_stencil(&JBIG2_RIGHT_HALF_BLACK, None)).expect("should decode");
1671        let Samples::Whole(Pixels::Stencil(BitImage {
1672            bits, row_bytes, ..
1673        })) = &image.samples
1674        else {
1675            panic!("expected a stencil, got {:?}", image.samples);
1676        };
1677        assert_eq!(*row_bytes, 1);
1678        assert_eq!(
1679            &bits[..],
1680            &[0b0000_1111u8; 8][..],
1681            "the codestream's black half is where the stencil inks"
1682        );
1683    }
1684
1685    #[test]
1686    fn a_jbig2_stencil_with_decode_one_zero_flips_every_bit() {
1687        // `/Decode [1 0]` reverses what a sample means, so the ink lands on
1688        // the half the codestream left white.
1689        let image =
1690            decode(&jbig2_stencil(&JBIG2_RIGHT_HALF_BLACK, Some([1, 0]))).expect("should decode");
1691        let Samples::Whole(Pixels::Stencil(BitImage { bits, .. })) = &image.samples else {
1692            panic!("expected a stencil, got {:?}", image.samples);
1693        };
1694        assert_eq!(&bits[..], &[0b1111_0000u8; 8][..]);
1695    }
1696
1697    #[test]
1698    fn a_jbig2_stencil_whose_codestream_will_not_decode_is_refused() {
1699        // The whole image fails rather than being painted from whatever the
1700        // undecoded bytes happen to look like: PDFium tears the half-built
1701        // bitmap down and draws nothing at all. `bug_527174.pdf` is this case
1702        // — a one-byte codestream that, read raw, inverted to a set bit and
1703        // painted a solid black square.
1704        let mut funcs = FunctionCache::new();
1705        let mut diags = Diagnostics::default();
1706        let got = decode_image(
1707            &jbig2_stencil(b"0", None),
1708            None,
1709            None,
1710            RequestedSize::Full,
1711            &NoResolve,
1712            &mut funcs,
1713            &Limits::default(),
1714            &mut diags,
1715        );
1716        assert!(
1717            got.is_err(),
1718            "an undecodable codestream is fatal, got {got:?}"
1719        );
1720        assert!(
1721            diags.contains(&DiagKind::ImageDecodeFailed),
1722            "the refusal is recorded, not silent: {:?}",
1723            diags.entries()
1724        );
1725    }
1726
1727    #[test]
1728    fn a_stencil_mask_with_the_default_decode_is_inverted() {
1729        let s = stream(
1730            vec![
1731                (Name::from("Width"), Object::Int(8)),
1732                (Name::from("Height"), Object::Int(1)),
1733                (Name::from("ImageMask"), Object::Bool(true)),
1734            ],
1735            &[0b1010_1010],
1736        );
1737        let image = decode(&s).expect("should decode");
1738        let Samples::Whole(Pixels::Stencil(BitImage { bits, .. })) = &image.samples else {
1739            panic!("expected a stencil, got {:?}", image.samples);
1740        };
1741        assert_eq!(bits.first(), Some(&0b0101_0101));
1742    }
1743
1744    #[test]
1745    fn a_stencil_mask_with_decode_one_zero_is_copied_verbatim() {
1746        let s = stream(
1747            vec![
1748                (Name::from("Width"), Object::Int(8)),
1749                (Name::from("Height"), Object::Int(1)),
1750                (Name::from("ImageMask"), Object::Bool(true)),
1751                (
1752                    Name::from("Decode"),
1753                    Object::Array(Array::of([Object::Int(1), Object::Int(0)])),
1754                ),
1755            ],
1756            &[0b1010_1010],
1757        );
1758        let image = decode(&s).expect("should decode");
1759        let Samples::Whole(Pixels::Stencil(BitImage { bits, .. })) = &image.samples else {
1760            panic!("expected a stencil");
1761        };
1762        assert_eq!(bits.first(), Some(&0b1010_1010));
1763    }
1764
1765    #[test]
1766    fn a_truncated_stream_is_zero_padded_rather_than_rejected() {
1767        let s = stream(
1768            vec![
1769                (Name::from("Width"), Object::Int(2)),
1770                (Name::from("Height"), Object::Int(2)),
1771                (Name::from("BitsPerComponent"), Object::Int(8)),
1772                (
1773                    Name::from("ColorSpace"),
1774                    Object::Name(Name::from("DeviceGray")),
1775                ),
1776            ],
1777            // Two bytes short of four.
1778            &[10, 20],
1779        );
1780        let image = decode(&s).expect("should still decode");
1781        assert_eq!(
1782            image.samples.to_pixels(),
1783            Pixels::Gray8(Box::from(&[10u8, 20, 0, 0][..]))
1784        );
1785    }
1786
1787    #[test]
1788    fn an_indexed_image_keeps_its_indices_and_a_palette() {
1789        let s = stream(
1790            vec![
1791                (Name::from("Width"), Object::Int(4)),
1792                (Name::from("Height"), Object::Int(1)),
1793                (Name::from("BitsPerComponent"), Object::Int(2)),
1794                (
1795                    Name::from("ColorSpace"),
1796                    Object::Array(Array::of([
1797                        Object::Name(Name::from("Indexed")),
1798                        Object::Name(Name::from("DeviceGray")),
1799                        Object::Int(3),
1800                        Object::Str(pdfrum_object::PdfString::literal([0u8, 85, 170, 255])),
1801                    ])),
1802                ),
1803            ],
1804            // Four two-bit indices: 0, 1, 2, 3.
1805            &[0b00_01_10_11],
1806        );
1807        let image = decode(&s).expect("should decode");
1808        let Samples::Whole(Pixels::Indexed { indices, palette }) = &image.samples else {
1809            panic!("expected indexed pixels, got {:?}", image.samples);
1810        };
1811        assert_eq!(&**indices, &[0, 1, 2, 3]);
1812        assert_eq!(palette.len(), 4);
1813        assert!(palette[0].r.abs() < 1e-6);
1814        assert!((palette[3].r - 1.0).abs() < 1e-6);
1815    }
1816
1817    #[test]
1818    fn a_bad_bit_depth_is_an_error_rather_than_a_repair() {
1819        let s = stream(
1820            vec![
1821                (Name::from("Width"), Object::Int(2)),
1822                (Name::from("Height"), Object::Int(2)),
1823                (Name::from("BitsPerComponent"), Object::Int(3)),
1824                (
1825                    Name::from("ColorSpace"),
1826                    Object::Name(Name::from("DeviceGray")),
1827                ),
1828            ],
1829            &[0; 16],
1830        );
1831        assert!(decode(&s).is_err());
1832    }
1833
1834    #[test]
1835    fn a_colour_key_mask_is_read_from_a_mask_array() {
1836        let s = stream(
1837            vec![
1838                (Name::from("Width"), Object::Int(2)),
1839                (Name::from("Height"), Object::Int(1)),
1840                (Name::from("BitsPerComponent"), Object::Int(8)),
1841                (
1842                    Name::from("ColorSpace"),
1843                    Object::Name(Name::from("DeviceGray")),
1844                ),
1845                (
1846                    Name::from("Mask"),
1847                    Object::Array(Array::of([Object::Int(0), Object::Int(10)])),
1848                ),
1849            ],
1850            &[5, 200],
1851        );
1852        // The array is read as a `ColorKey` and then *resolved* against the
1853        // raw samples before the image leaves this crate: sample 5 falls in
1854        // `0..=10` and goes transparent, 200 does not and stays opaque. The
1855        // key itself never reaches a renderer, because by draw time the raw
1856        // samples the predicate needs are gone.
1857        let image = decode(&s).expect("should decode");
1858        let Some(super::ImageMask::Alpha { alpha, .. }) = &image.mask else {
1859            panic!("expected a resolved alpha plane, got {:?}", image.mask);
1860        };
1861        assert_eq!(&**alpha, &[0u8, 255]);
1862        // The predicate itself still reads the way the array wrote it.
1863        let key =
1864            super::ColorKey::from_array(&Array::of([Object::Int(0), Object::Int(10)]), 1, 255);
1865        assert!(key.is_transparent(&[5]));
1866        assert!(!key.is_transparent(&[200]));
1867    }
1868
1869    #[test]
1870    fn pixel_lookup_is_bounds_checked() {
1871        let pixels = Samples::Whole(Pixels::Rgb8(Box::from(&[255u8, 0, 0, 0, 255, 0][..])));
1872        assert_eq!(sample_at(&pixels, 0, 0, 2), [255, 0, 0]);
1873        // Out of range reads as black rather than panicking.
1874        assert_eq!(sample_at(&pixels, 99, 99, 2), [0, 0, 0]);
1875        assert_eq!(pixels.components(), 3);
1876    }
1877
1878    /// An image dictionary for the codec `/Decode` tests.
1879    fn codec_dict(space: &str, decode: Option<Vec<f32>>) -> super::ImageDict {
1880        let mut pairs = vec![
1881            (Name::from("Width"), Object::Int(2)),
1882            (Name::from("Height"), Object::Int(1)),
1883            (Name::from("BitsPerComponent"), Object::Int(8)),
1884            (Name::from("ColorSpace"), Object::Name(Name::from(space))),
1885            (Name::from("Filter"), Object::Name(Name::from("DCTDecode"))),
1886        ];
1887        if let Some(values) = decode {
1888            pairs.push((
1889                Name::from("Decode"),
1890                Object::Array(values.into_iter().map(Object::Real).collect()),
1891            ));
1892        }
1893        let mut diags = Diagnostics::default();
1894        super::ImageDict::load(&Dict::from_pairs(pairs), &NoResolve, &mut diags)
1895            .expect("the fixture dictionary should load")
1896    }
1897
1898    #[test]
1899    fn a_decode_array_reaches_a_codecs_output_too() {
1900        // `TranslateScanline24bpp` runs on the decoder's scanline, so the
1901        // Adobe inversion a CMYK JPEG carries as `/Decode [1 0 …]` has to be
1902        // applied to the codec's bytes — `bug_1646` and `bug_718762`.
1903        let info = codec_dict(
1904            "DeviceCMYK",
1905            Some(vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0]),
1906        );
1907        assert!(!info.default_decode);
1908        let space = crate::color::ColorSpace::DeviceCmyk;
1909        let mut data = vec![255u8, 0, 0, 253, 0, 255, 255, 2];
1910        super::apply_codec_decode(&mut data, Some(&space), 4, &info);
1911        assert_eq!(data, vec![0u8, 255, 255, 2, 255, 0, 0, 253]);
1912    }
1913
1914    #[test]
1915    fn the_default_decode_leaves_a_codecs_output_untouched() {
1916        // The default mapping is the identity by construction, so it is
1917        // skipped rather than run — no rounding drift on the common path.
1918        let info = codec_dict("DeviceCMYK", None);
1919        assert!(info.default_decode);
1920        let space = crate::color::ColorSpace::DeviceCmyk;
1921        let original = vec![255u8, 0, 0, 253, 1, 2, 3, 4];
1922        let mut data = original.clone();
1923        super::apply_codec_decode(&mut data, Some(&space), 4, &info);
1924        assert_eq!(data, original);
1925        // An explicit array that *equals* the default is skipped as well.
1926        let info = codec_dict(
1927            "DeviceCMYK",
1928            Some(vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0]),
1929        );
1930        let mut data = original.clone();
1931        super::apply_codec_decode(&mut data, Some(&space), 4, &info);
1932        assert_eq!(data, original);
1933    }
1934
1935    /// The row conversion is the colour space's own answer, over every input.
1936    ///
1937    /// [`super::rows::Converted`] replaced a per-pixel `sample_bytes`, which
1938    /// had in turn replaced a float round trip through [`Rgb`]. Both
1939    /// replacements were justified by being *exactly* the thing they replaced,
1940    /// so the claim is checked over every input each variant can take rather
1941    /// than over a sample of them — the float path is written out here as the
1942    /// reference, because it is the one nobody would accuse of being an
1943    /// optimisation.
1944    #[test]
1945    fn the_row_conversion_is_exactly_the_float_path() {
1946        // The round trip the whole argument rests on, both directions, all
1947        // 256: a byte through `f32 / 255.0` and back through
1948        // `(v.clamp(0, 1) * 255).round()` is the identity, so a conversion
1949        // that skips the floats cannot differ from one that does not.
1950        for b in 0..=255u8 {
1951            let there = f32::from(b) / 255.0;
1952            let back = Rgb {
1953                r: there,
1954                g: there,
1955                b: there,
1956            }
1957            .to_bytes();
1958            assert_eq!(back, [b, b, b], "byte {b} does not survive the float trip");
1959        }
1960
1961        // Grey: every byte, widened to three equal channels.
1962        let gray = Samples::Whole(Pixels::Gray8((0..=255u8).collect()));
1963        for x in 0..256u32 {
1964            let v = u8::try_from(x).expect("x < 256");
1965            assert_eq!(sample_at(&gray, x, 0, 256), [v, v, v], "gray {x}");
1966        }
1967
1968        // RGB: a walk that puts every byte in every channel position.
1969        let rgb = Samples::Whole(Pixels::Rgb8(
1970            (0..=255u8).flat_map(|v| [v, 255 - v, v / 2]).collect(),
1971        ));
1972        for x in 0..256u32 {
1973            let v = u8::try_from(x).expect("x < 256");
1974            assert_eq!(sample_at(&rgb, x, 0, 256), [v, 255 - v, v / 2], "rgb {x}");
1975        }
1976
1977        // CMYK is the one with real arithmetic in it. The full domain is 2^32,
1978        // so this walks a lattice that hits every value on every axis, plus the
1979        // saturated corners the interpolation treats specially.
1980        let mut cmyk = Vec::new();
1981        let step = 17u16; // 0, 17, ... 255 — sixteen values, exact at both ends.
1982        for c in (0..=255u16).step_by(step as usize) {
1983            for m in (0..=255u16).step_by(step as usize) {
1984                for y in (0..=255u16).step_by(step as usize) {
1985                    for k in (0..=255u16).step_by(step as usize) {
1986                        cmyk.extend_from_slice(&[c as u8, m as u8, y as u8, k as u8]);
1987                    }
1988                }
1989            }
1990        }
1991        let count = cmyk.len() / 4;
1992        let raw = cmyk.clone();
1993        let cmyk = Samples::Whole(Pixels::Cmyk8(cmyk.into()));
1994        // The whole lattice is one row, converted once — which is how the
1995        // pipeline is meant to be used. Calling `sample_at` per point would
1996        // rebuild the row buffer for each of the 65 536 of them.
1997        let row = converted_row(&cmyk, count as u32, 0);
1998        for (x, got) in row.iter().enumerate() {
1999            let at = x * 4;
2000            let want = crate::color::ColorSpace::DeviceCmyk
2001                .to_rgb(&[
2002                    f32::from(raw[at]) / 255.0,
2003                    f32::from(raw[at + 1]) / 255.0,
2004                    f32::from(raw[at + 2]) / 255.0,
2005                    f32::from(raw[at + 3]) / 255.0,
2006                ])
2007                .to_bytes();
2008            assert_eq!(*got, want, "cmyk lattice point {x}");
2009        }
2010
2011        // Indexed, whose palette the row pipeline encodes once rather than per
2012        // pixel — the same answer, which is the whole point of `Palette`.
2013        let palette: Box<[Rgb]> = (0..=255u8)
2014            .map(|v| Rgb {
2015                r: f32::from(v) / 255.0,
2016                g: f32::from(255 - v) / 255.0,
2017                b: 0.25,
2018            })
2019            .collect();
2020        let indexed = Samples::Whole(Pixels::Indexed {
2021            indices: (0..=255u8).collect(),
2022            palette: palette.clone(),
2023        });
2024        for x in 0..256u32 {
2025            let want = palette[x as usize].to_bytes();
2026            assert_eq!(sample_at(&indexed, x, 0, 256), want, "indexed {x}");
2027        }
2028
2029        // A stencil, both phases: a set bit is ink and reads 0, a clear one
2030        // reads 255. The stencil's own colour is applied later, by the render
2031        // path, so the conversion only has to preserve that convention.
2032        let bits = BitImage {
2033            width: 2,
2034            height: 1,
2035            row_bytes: 1,
2036            bits: vec![0b1000_0000],
2037        };
2038        let stencil = Samples::Whole(Pixels::Stencil(bits));
2039        assert_eq!(sample_at(&stencil, 0, 0, 2), [0, 0, 0], "a set bit is ink");
2040        assert_eq!(
2041            sample_at(&stencil, 1, 0, 2),
2042            [255, 255, 255],
2043            "a clear bit is paper"
2044        );
2045
2046        // And an out-of-range read is black on every variant rather than a
2047        // panic, which is the fallback the per-pixel path carried.
2048        for p in [&gray, &rgb, &cmyk, &indexed, &stencil] {
2049            assert_eq!(
2050                sample_at(p, 9999, 9999, 256),
2051                [0, 0, 0],
2052                "an out-of-range read is black"
2053            );
2054        }
2055    }
2056
2057    /// [`super::mask_plane`]'s grey fast arm must be its general arm, which
2058    /// is the row pipeline, which is in turn the float path by the test
2059    /// above. Asserted here rather than argued in the comment, because the
2060    /// conformance gate can only see the mask shapes the corpus happens to
2061    /// carry and an `Indexed` `/SMask` is not one of them.
2062    #[test]
2063    fn the_mask_planes_fast_arms_are_the_general_one() {
2064        let general = |pixels: &Samples, w: u32, h: u32| -> Vec<u8> {
2065            (0..h)
2066                .flat_map(|y| (0..w).map(move |x| (x, y)))
2067                .map(|(x, y)| sample_at(pixels, x, y, w)[0])
2068                .collect()
2069        };
2070
2071        // Grey, at the exact length, short, and long.
2072        for (w, h, len) in [(4_u32, 3_u32, 12_usize), (4, 3, 7), (4, 3, 20), (1, 1, 1)] {
2073            let data: Box<[u8]> = (0..len).map(|i| (i * 31 % 256) as u8).collect();
2074            let pixels = Samples::Whole(Pixels::Gray8(data));
2075            let got = super::mask_plane(&pixels, w, h).expect("dimensions multiply");
2076            let mut want = general(&pixels, w, h);
2077            want.resize((w * h) as usize, 0);
2078            assert_eq!(&got[..], &want[..], "gray {w}x{h}, {len} bytes");
2079        }
2080
2081        // Indexed, whose palette the fast arm encodes once.
2082        let palette: Box<[Rgb]> = (0..=255u8)
2083            .map(|v| Rgb {
2084                r: f32::from(v) / 255.0,
2085                g: 0.5,
2086                b: 0.25,
2087            })
2088            .collect();
2089        for (w, h, len) in [(8_u32, 4_u32, 32_usize), (8, 4, 10)] {
2090            let pixels = Samples::Whole(Pixels::Indexed {
2091                indices: (0..len).map(|i| (i * 7 % 256) as u8).collect(),
2092                palette: palette.clone(),
2093            });
2094            let got = super::mask_plane(&pixels, w, h).expect("dimensions multiply");
2095            let mut want = general(&pixels, w, h);
2096            want.resize((w * h) as usize, 0);
2097            assert_eq!(&got[..], &want[..], "indexed {w}x{h}, {len} indices");
2098        }
2099
2100        // And the general arm still answers for the kinds that have no fast
2101        // one, so a mask that is not grey is not silently dropped.
2102        let rgb = Samples::Whole(Pixels::Rgb8((0..24u8).collect()));
2103        let got = super::mask_plane(&rgb, 4, 2).expect("dimensions multiply");
2104        assert_eq!(&got[..], &general(&rgb, 4, 2)[..]);
2105    }
2106
2107    /// The product cap, not each axis: 65536 square sits inside the
2108    /// dictionary gate and is 4.3 Gpx.
2109    #[test]
2110    fn a_gigapixel_image_is_not_a_workable_area() {
2111        assert!(
2112            super::image_area_is_workable(20_000, 28_000),
2113            "A0 at 600dpi"
2114        );
2115        assert!(!super::image_area_is_workable(65_536, 65_536), "4.3 Gpx");
2116        assert!(!super::image_area_is_workable(131_071, 131_071), "17 Gpx");
2117        assert!(super::image_area_is_workable(2, 2));
2118    }
2119
2120    /// A mask plane past the area cap is dropped, it does not abort or hang.
2121    ///
2122    /// `mask_plane`'s buffer is the mask's own `/Width` x `/Height` at one
2123    /// byte a pixel, and the dictionary gate accepts each axis up to 131071 —
2124    /// a 17 GB `Vec` reached from a base image of any size, because a mask is
2125    /// never resolution-reduced. `try_reserve_exact` is not a reliable refusal
2126    /// for that: Linux overcommit lets the reserve succeed, and zeroing the
2127    /// buffer then OOMs the host. [`super::image_area_is_workable`] is what
2128    /// turns it into `None` without touching the allocator.
2129    #[test]
2130    fn a_mask_plane_too_large_to_allocate_is_dropped_rather_than_aborting() {
2131        let pixels = Samples::Whole(Pixels::Gray8(Box::new([0u8; 4])));
2132        assert_eq!(super::mask_plane(&pixels, 131_071, 131_071), None);
2133        // 65536 square is inside each axis cap and is 4.3 Gpx — the hang the
2134        // render path already refuses, and the size that would still pass a
2135        // reserve-only check on an overcommit host.
2136        assert_eq!(super::mask_plane(&pixels, 65_536, 65_536), None);
2137        // The same samples at a size that fits still produce a plane.
2138        assert!(super::mask_plane(&pixels, 2, 2).is_some());
2139    }
2140
2141    #[test]
2142    fn the_decode_table_is_exhaustively_the_per_sample_arithmetic() {
2143        // The table replaces a loop that ran the float mapping on every byte of
2144        // the image. It is only allowed to be faster, never different — so this
2145        // asserts the equality on *every* input the mapping can receive:
2146        // each of the four component slots, each of the 256 byte values, over
2147        // several arrays that reach different corners of the arithmetic
2148        // (the Adobe inversion, an asymmetric range, one that clamps at both
2149        // ends, and a degenerate zero-width range).
2150        let arrays: [Vec<f32>; 4] = [
2151            vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0],
2152            vec![0.0, 0.5, 0.25, 1.0, 0.1, 0.9, 0.0, 1.0],
2153            vec![-1.0, 2.0, 2.0, -1.0, -0.5, 1.5, 1.5, -0.5],
2154            vec![0.3, 0.3, 0.0, 1.0, 1.0, 0.0, 0.7, 0.2],
2155        ];
2156        let space = crate::color::ColorSpace::DeviceCmyk;
2157        for values in arrays {
2158            let info = codec_dict("DeviceCMYK", Some(values));
2159            let decode = super::DecodeMap::new(Some(&space), 4, 8, info.decode.as_ref());
2160            let table = super::decode_table(&decode, 4);
2161            for (component, row) in table.iter().enumerate() {
2162                for raw in 0..=255u8 {
2163                    let value = decode.apply(component, f32::from(raw));
2164                    let expected = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
2165                    assert_eq!(
2166                        row[usize::from(raw)],
2167                        expected,
2168                        "component {component}, raw {raw}"
2169                    );
2170                }
2171            }
2172            assert_eq!(table.len(), 4, "one row per component");
2173        }
2174    }
2175
2176    #[test]
2177    fn a_trailing_partial_pixel_maps_by_its_position_in_the_pixel() {
2178        // The per-sample loop keyed on `i % components`, so a buffer whose
2179        // length is not a whole number of pixels still mapped its last bytes as
2180        // components 0, 1, … The chunked loop has to agree, which it does only
2181        // because `chunks_mut` yields the short trailing chunk rather than
2182        // dropping it. Four components, six bytes: the last two are components
2183        // 0 and 1 of a pixel that is not all there.
2184        let info = codec_dict(
2185            "DeviceCMYK",
2186            Some(vec![1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0]),
2187        );
2188        let space = crate::color::ColorSpace::DeviceCmyk;
2189        let mut data = vec![10u8, 20, 30, 40, 50, 60];
2190        super::apply_codec_decode(&mut data, Some(&space), 4, &info);
2191        // Components 0 and 2 invert; 1 and 3 are the identity.
2192        assert_eq!(data, vec![245u8, 20, 225, 40, 205, 60]);
2193    }
2194
2195    #[test]
2196    fn a_codec_decode_needs_a_space_and_some_components() {
2197        let info = codec_dict("DeviceGray", Some(vec![1.0, 0.0]));
2198        let original = vec![10u8, 200];
2199        // No colour space, or no components, and nothing happens.
2200        let mut data = original.clone();
2201        super::apply_codec_decode(&mut data, None, 1, &info);
2202        assert_eq!(data, original);
2203        let mut data = original.clone();
2204        let gray = crate::color::ColorSpace::DeviceGray;
2205        super::apply_codec_decode(&mut data, Some(&gray), 0, &info);
2206        assert_eq!(data, original);
2207        // With both, grey inverts.
2208        let mut data = original.clone();
2209        super::apply_codec_decode(&mut data, Some(&gray), 1, &info);
2210        assert_eq!(data, vec![245u8, 55]);
2211    }
2212
2213    /// A Group 4 stream of `rows` all-white rows.
2214    ///
2215    /// One vertical-zero code — a single set bit — carries a row whose first
2216    /// changing element is the row's end, which against an all-white reference
2217    /// line is an all-white row.
2218    fn all_white_g4(rows: usize) -> Vec<u8> {
2219        let mut byte = 0u8;
2220        for i in 0..rows.min(8) {
2221            byte |= 1 << (7 - i);
2222        }
2223        vec![byte]
2224    }
2225
2226    /// A Group 4 stream whose first row is eight black pixels then white, and
2227    /// whose remaining rows repeat it.
2228    ///
2229    /// Horizontal mode (`001`) with a zero-length white run (`00110101`) and an
2230    /// eight-long black run (`000101`); each further row is a vertical-zero
2231    /// code (`1`), which copies the row above.
2232    fn black_then_white_g4(rows: usize) -> Vec<u8> {
2233        let mut bits = String::from("001001101010001011");
2234        for _ in 1..rows {
2235            bits.push('1');
2236        }
2237        while bits.len() % 8 != 0 {
2238            bits.push('0');
2239        }
2240        bits.as_bytes()
2241            .chunks(8)
2242            .filter_map(|c| {
2243                let s = std::str::from_utf8(c).ok()?;
2244                u8::from_str_radix(s, 2).ok()
2245            })
2246            .collect()
2247    }
2248
2249    fn ccitt_stream(width: i64, height: i64, mask: bool, data: &[u8]) -> Stream {
2250        let parms = Dict::from_pairs(vec![
2251            (Name::from("K"), Object::Int(-1)),
2252            (Name::from("Columns"), Object::Int(width)),
2253            (Name::from("Rows"), Object::Int(height)),
2254        ]);
2255        let mut pairs = vec![
2256            (Name::from("Width"), Object::Int(width)),
2257            (Name::from("Height"), Object::Int(height)),
2258            (Name::from("BitsPerComponent"), Object::Int(1)),
2259            (
2260                Name::from("Filter"),
2261                Object::Name(Name::from("CCITTFaxDecode")),
2262            ),
2263            (Name::from("DecodeParms"), Object::Dict(parms)),
2264        ];
2265        if mask {
2266            pairs.push((Name::from("ImageMask"), Object::Bool(true)));
2267        } else {
2268            pairs.push((
2269                Name::from("ColorSpace"),
2270                Object::Name(Name::from("DeviceGray")),
2271            ));
2272        }
2273        stream(pairs, data)
2274    }
2275
2276    #[test]
2277    fn a_fax_image_reaches_the_decoder_at_all() {
2278        // The chain classifies `/CCITTFaxDecode` as an image codec and hands
2279        // its bytes back undecoded, so without a caller in the image path the
2280        // codestream was unpacked as if it were already samples. An all-white
2281        // image is the smallest thing that tells the two apart: decoded it is
2282        // white, undecoded the single data byte `0xE0` paints three black
2283        // pixels across the top row.
2284        let s = ccitt_stream(20, 3, false, &all_white_g4(3));
2285        let image = decode(&s).expect("should decode");
2286        assert_eq!((image.width, image.height), (20, 3));
2287        for y in 0..3 {
2288            for x in 0..20 {
2289                assert_eq!(
2290                    sample_at(&image.samples, x, y, 20),
2291                    [255, 255, 255],
2292                    "({x},{y}) should be white"
2293                );
2294            }
2295        }
2296    }
2297
2298    #[test]
2299    fn a_fax_row_is_repacked_from_four_byte_padding_to_the_images_pitch() {
2300        // The decoder pads a row to four bytes; every consumer here reads rows
2301        // at `width.div_ceil(8)`. At width 20 those are 4 and 3, so reading the
2302        // decoder's buffer at the image's pitch would start each row a byte
2303        // further into the previous one and shear the picture. Three rows of a
2304        // 20-wide image is 9 sample bytes, not 12.
2305        //
2306        // The pattern has to be non-uniform for the shear to show. Rows 0 and 1
2307        // are eight black pixels then white; row 2 is the decoder's white
2308        // prefill, because the byte padding ends the codestream before it.
2309        // Read at the decoder's stride instead of the image's, row 1's black
2310        // byte would land four bytes on — a third of the way into row 1's
2311        // pixels rather than at its start.
2312        let s = ccitt_stream(20, 3, false, &black_then_white_g4(3));
2313        let image = decode(&s).expect("should decode");
2314        let black = [0_u8, 0, 0];
2315        let white = [255_u8, 255, 255];
2316        for y in 0..3 {
2317            for x in 0..20 {
2318                let want = if y < 2 && x < 8 { black } else { white };
2319                assert_eq!(
2320                    sample_at(&image.samples, x, y, 20),
2321                    want,
2322                    "({x},{y}) — a shear puts the black run somewhere else"
2323                );
2324            }
2325        }
2326    }
2327
2328    #[test]
2329    fn a_fax_stream_that_will_not_decode_leaves_the_image_white() {
2330        // The decoder pre-fills white and gives back the rows it managed; the
2331        // repack keeps that, so damage is blank rather than black or an error.
2332        let s = ccitt_stream(20, 3, false, &[0x00, 0x00]);
2333        let image = decode(&s).expect("damage is not a failure");
2334        assert_eq!(sample_at(&image.samples, 0, 0, 20), [255, 255, 255]);
2335    }
2336
2337    /// Build a `[/Separation /Name /DeviceCMYK <tint transform>]` array whose
2338    /// transform is the type-2 exponential `C0 -> C1` at `N = 1`.
2339    fn separation_cmyk(c1: [f32; 4]) -> Object {
2340        let mut c0 = Array::default();
2341        for _ in 0..4 {
2342            c0.push(Object::Real(0.0));
2343        }
2344        let mut c1_arr = Array::default();
2345        for v in c1 {
2346            c1_arr.push(Object::Real(v));
2347        }
2348        let mut domain = Array::default();
2349        domain.push(Object::Int(0));
2350        domain.push(Object::Int(1));
2351        let mut range = Array::default();
2352        for _ in 0..4 {
2353            range.push(Object::Int(0));
2354            range.push(Object::Int(1));
2355        }
2356        let tint = Dict::from_pairs(vec![
2357            (Name::from("FunctionType"), Object::Int(2)),
2358            (Name::from("N"), Object::Real(1.0)),
2359            (Name::from("Domain"), Object::Array(domain)),
2360            (Name::from("Range"), Object::Array(range)),
2361            (Name::from("C0"), Object::Array(c0)),
2362            (Name::from("C1"), Object::Array(c1_arr)),
2363        ]);
2364        let mut space = Array::default();
2365        space.push(Object::Name(Name::from("Separation")));
2366        space.push(Object::Name(Name::from("Spot")));
2367        space.push(Object::Name(Name::from("DeviceCMYK")));
2368        space.push(Object::Dict(tint));
2369        Object::Array(space)
2370    }
2371
2372    #[test]
2373    fn a_separation_image_runs_its_samples_through_the_tint_transform() {
2374        // `corpus/fx/other/1.pdf`'s own image, reduced to its essentials: one
2375        // eight-bit sample of `0xC6` in a `/Separation` whose alternate is
2376        // `DeviceCMYK` and whose `C1` is PANTONE 327 CV. The tint is
2377        // 198/255 = 0.7765, so the CMYK is (0.7765, 0, 0.4659, 0) and the
2378        // Adobe table turns that into teal.
2379        //
2380        // Read as a grey level instead — which is what a `Pixels::Gray8`
2381        // bucketed on the component count does — the same byte paints
2382        // (198, 198, 198). That was the defect: the sample is a *tint*, and
2383        // only the tint transform makes it a colour.
2384        let s = stream(
2385            vec![
2386                (Name::from("Width"), Object::Int(1)),
2387                (Name::from("Height"), Object::Int(1)),
2388                (Name::from("BitsPerComponent"), Object::Int(8)),
2389                (
2390                    Name::from("ColorSpace"),
2391                    separation_cmyk([1.0, 0.0, 0.600_006, 0.0]),
2392                ),
2393            ],
2394            &[0xC6],
2395        );
2396        let image = decode(&s).expect("should decode");
2397        assert_eq!(
2398            sample_at(&image.samples, 0, 0, 1),
2399            [0, 182, 162],
2400            "the tint must reach the alternate space, not the page as grey"
2401        );
2402        // The shape matters as much as the colour: a palette is what the
2403        // renderer's indexed fast path consumes, and it must span the whole
2404        // eight-bit sample domain rather than only the values in use.
2405        let Samples::Whole(Pixels::Indexed { palette, .. }) = &image.samples else {
2406            panic!(
2407                "a resolved Separation image is a palette, got {:?}",
2408                image.samples
2409            );
2410        };
2411        assert_eq!(palette.len(), 256);
2412        // A zero tint is `C0`, which is CMYK all-zero — paper white.
2413        assert_eq!(palette[0].to_bytes(), [255, 255, 255]);
2414    }
2415
2416    #[test]
2417    fn a_separation_decode_array_is_folded_into_the_palette() {
2418        // `/Decode [1 0]` inverts the *tint* before the transform runs, so
2419        // the `0xC6` sample becomes a 1 - 0.7765 = 0.2235 tint rather than a
2420        // 0.7765 one. `LoadPalette` folds `decode_min_ + decode_step_ * i`
2421        // into each entry, and so must this.
2422        let mut decode_arr = Array::default();
2423        decode_arr.push(Object::Int(1));
2424        decode_arr.push(Object::Int(0));
2425        let s = stream(
2426            vec![
2427                (Name::from("Width"), Object::Int(1)),
2428                (Name::from("Height"), Object::Int(1)),
2429                (Name::from("BitsPerComponent"), Object::Int(8)),
2430                (
2431                    Name::from("ColorSpace"),
2432                    separation_cmyk([1.0, 0.0, 0.600_006, 0.0]),
2433                ),
2434                (Name::from("Decode"), Object::Array(decode_arr)),
2435            ],
2436            &[0xC6],
2437        );
2438        let image = decode(&s).expect("should decode");
2439        let inverted = sample_at(&image.samples, 0, 0, 1);
2440        let s_plain = stream(
2441            vec![
2442                (Name::from("Width"), Object::Int(1)),
2443                (Name::from("Height"), Object::Int(1)),
2444                (Name::from("BitsPerComponent"), Object::Int(8)),
2445                (
2446                    Name::from("ColorSpace"),
2447                    separation_cmyk([1.0, 0.0, 0.600_006, 0.0]),
2448                ),
2449            ],
2450            &[255 - 0xC6],
2451        );
2452        let plain = decode(&s_plain).expect("should decode");
2453        assert_eq!(
2454            inverted,
2455            sample_at(&plain.samples, 0, 0, 1),
2456            "`/Decode [1 0]` on a tint is the complement of the sample"
2457        );
2458    }
2459
2460    #[test]
2461    fn a_devicen_image_converts_per_pixel_rather_than_through_a_palette() {
2462        // Two colorants cannot be tabulated over an eight-bit sample, so this
2463        // takes `TranslateScanline24bpp`'s per-pixel shape and resolves to
2464        // `Rgb8`. The transform is a type-2 exponential from all-zero to
2465        // `C1`, evaluated on the *first* input only, which is what a
2466        // one-output-per-colorant `DeviceN` degenerates to here — the point of
2467        // the fixture is the arm taken, and that both colorants reach it.
2468        let mut names = Array::default();
2469        names.push(Object::Name(Name::from("SpotA")));
2470        names.push(Object::Name(Name::from("SpotB")));
2471        let mut domain = Array::default();
2472        for _ in 0..2 {
2473            domain.push(Object::Int(0));
2474            domain.push(Object::Int(1));
2475        }
2476        let mut range = Array::default();
2477        for _ in 0..4 {
2478            range.push(Object::Int(0));
2479            range.push(Object::Int(1));
2480        }
2481        let mut c0 = Array::default();
2482        let mut c1 = Array::default();
2483        for _ in 0..4 {
2484            c0.push(Object::Real(0.0));
2485        }
2486        for v in [0.0_f32, 1.0, 1.0, 0.0] {
2487            c1.push(Object::Real(v));
2488        }
2489        let tint = Dict::from_pairs(vec![
2490            (Name::from("FunctionType"), Object::Int(2)),
2491            (Name::from("N"), Object::Real(1.0)),
2492            (Name::from("Domain"), Object::Array(domain)),
2493            (Name::from("Range"), Object::Array(range)),
2494            (Name::from("C0"), Object::Array(c0)),
2495            (Name::from("C1"), Object::Array(c1)),
2496        ]);
2497        let mut space = Array::default();
2498        space.push(Object::Name(Name::from("DeviceN")));
2499        space.push(Object::Array(names));
2500        space.push(Object::Name(Name::from("DeviceCMYK")));
2501        space.push(Object::Dict(tint));
2502        let s = stream(
2503            vec![
2504                (Name::from("Width"), Object::Int(2)),
2505                (Name::from("Height"), Object::Int(1)),
2506                (Name::from("BitsPerComponent"), Object::Int(8)),
2507                (Name::from("ColorSpace"), Object::Array(space)),
2508            ],
2509            &[0x00, 0x00, 0xFF, 0xFF],
2510        );
2511        let image = decode(&s).expect("should decode");
2512        assert!(
2513            matches!(image.samples, Samples::Whole(Pixels::Rgb8(_))),
2514            "a two-colorant DeviceN resolves per pixel and cannot stay packed, \
2515             got {:?}",
2516            image.samples
2517        );
2518        // A zero tint vector is `C0` — CMYK all-zero, paper white — and a
2519        // full one is `C1`, pure red in the alternate. Neither is the raw
2520        // sample pair, which is the whole point.
2521        assert_eq!(sample_at(&image.samples, 0, 0, 2), [255, 255, 255]);
2522        let full = sample_at(&image.samples, 1, 0, 2);
2523        assert!(
2524            full[0] > 200 && full[1] < 80 && full[2] < 80,
2525            "a full tint must reach the alternate space's red, got {full:?}"
2526        );
2527    }
2528}