Skip to main content

oxideav_tiff/
decoder.rs

1//! High-level TIFF 6.0 decode: parse the header + first IFD,
2//! decompress every strip, assemble the image, apply the predictor
3//! if any, expand palette / bilevel / 16-bit pixels into one of our
4//! standard `TiffPixelFormat`s.
5
6use crate::ccitt::{decode_ccitt, reverse_bits_in_place, CcittVariant, FillOrder};
7use crate::compress::{unpack_deflate, unpack_lzw, unpack_packbits, unpack_zstd};
8use crate::error::{Result, TiffError as Error};
9use crate::ifd::{find, parse_header, parse_ifd, ByteOrder, Entry};
10use crate::image::{TiffImage, TiffPixelFormat, TiffPlane};
11use crate::types::*;
12
13/// Maximum total pixels (`ImageWidth * ImageLength`) the decoder will
14/// accept on a single IFD. Computed up-front from the IFD's
15/// `ImageWidth` / `ImageLength` tags before any strip or tile buffer
16/// is allocated, so a 16-byte attacker-crafted IFD claiming
17/// `4294967295 * 4294967295` pixels can't drive a multi-petabyte
18/// upfront `Vec::with_capacity`. 256 megapixels covers every
19/// legitimate single-image TIFF in the wild (it is ~70% larger than
20/// a "Hubble Ultra-Deep Field" mosaic at full resolution); the cap
21/// can be lifted by a forward-compatible release if a real workflow
22/// ever needs it.
23const MAX_IMAGE_PIXELS: u64 = 256 * 1024 * 1024;
24
25/// Outcome of a successful decode: the image plus the resolved pixel
26/// format and dimensions (handy for tests / containers).
27///
28/// Identical in shape to [`TiffImage`] — kept as a distinct alias so
29/// the historical `DecodedTiff { frame, width, height, pixel_format }`
30/// shape stays available to callers.
31pub struct DecodedTiff {
32    pub frame: TiffImage,
33    pub width: u32,
34    pub height: u32,
35    pub pixel_format: TiffPixelFormat,
36}
37
38/// Decode the first IFD of a TIFF/BigTIFF file. Multi-page callers
39/// should reach for [`decode_tiff_all`] instead.
40pub fn decode_tiff(input: &[u8]) -> Result<DecodedTiff> {
41    let header = parse_header(input)?;
42    let bo = header.byte_order;
43    let variant = header.variant;
44    let (entries, _next_ifd) = parse_ifd(input, bo, variant, header.first_ifd_offset)?;
45    let frame = decode_ifd(input, bo, &entries)?;
46    let pf = frame.pixel_format;
47    Ok(DecodedTiff {
48        width: frame.width,
49        height: frame.height,
50        pixel_format: pf,
51        frame,
52    })
53}
54
55/// Decode every IFD in the file (all pages of a multi-page TIFF /
56/// BigTIFF). Returns one [`TiffImage`] per IFD in file order.
57pub fn decode_tiff_all(input: &[u8]) -> Result<Vec<TiffImage>> {
58    let header = parse_header(input)?;
59    let bo = header.byte_order;
60    let variant = header.variant;
61    let mut out = Vec::new();
62    let mut next = header.first_ifd_offset;
63    let mut visited: Vec<u64> = Vec::new();
64    while next != 0 {
65        // Cycle guard: the next-IFD chain must not loop. Unbounded
66        // input is hostile data; check that we haven't already seen
67        // this offset.
68        if visited.contains(&next) {
69            return Err(Error::invalid("TIFF: cyclic next-IFD pointer"));
70        }
71        visited.push(next);
72        let (entries, n) = parse_ifd(input, bo, variant, next)?;
73        out.push(decode_ifd(input, bo, &entries)?);
74        next = n;
75    }
76    if out.is_empty() {
77        return Err(Error::invalid("TIFF: no IFDs in file"));
78    }
79    Ok(out)
80}
81
82/// Decode one IFD (already parsed into `entries`) into a [`TiffImage`].
83fn decode_ifd(input: &[u8], bo: ByteOrder, entries: &[Entry]) -> Result<TiffImage> {
84    // ---- Mandatory tags ----
85    let width = find(entries, TAG_IMAGE_WIDTH)
86        .ok_or_else(|| Error::invalid("TIFF: missing ImageWidth"))?
87        .as_u32(bo)?;
88    let height = find(entries, TAG_IMAGE_LENGTH)
89        .ok_or_else(|| Error::invalid("TIFF: missing ImageLength"))?
90        .as_u32(bo)?;
91    if width == 0 || height == 0 {
92        return Err(Error::invalid("TIFF: zero dimension"));
93    }
94    // Sanity gate: reject claims that exceed `MAX_IMAGE_PIXELS` up
95    // front so the downstream `row_bytes * height` allocations
96    // can't be steered into multi-gibibyte territory by a 16-byte
97    // attacker-crafted IFD. 256 megapixels covers every legitimate
98    // single-image TIFF (a 16384x16384 RGB16 file is 1.5 GiB raw
99    // but only 268 megapixels) while bounding the worst-case
100    // upfront allocation to ~256 MiB even at 16-bit-per-component
101    // RGB.
102    if (width as u64).saturating_mul(height as u64) > MAX_IMAGE_PIXELS {
103        return Err(Error::invalid(format!(
104            "TIFF: image too large ({width}x{height} > {MAX_IMAGE_PIXELS} pixels)"
105        )));
106    }
107
108    let compression = find(entries, TAG_COMPRESSION)
109        .map(|e| e.as_u32(bo))
110        .transpose()?
111        .unwrap_or(COMPRESSION_NONE as u32) as u16;
112    let photometric = find(entries, TAG_PHOTOMETRIC_INTERPRETATION)
113        .map(|e| e.as_u32(bo))
114        .transpose()?
115        .ok_or_else(|| Error::invalid("TIFF: missing PhotometricInterpretation"))?
116        as u16;
117    let samples_per_pixel = find(entries, TAG_SAMPLES_PER_PIXEL)
118        .map(|e| e.as_u32(bo))
119        .transpose()?
120        .unwrap_or(1) as u16;
121    let bits_per_sample =
122        decode_bits_per_sample(find(entries, TAG_BITS_PER_SAMPLE), bo, samples_per_pixel)?;
123    let planar = find(entries, TAG_PLANAR_CONFIGURATION)
124        .map(|e| e.as_u32(bo))
125        .transpose()?
126        .unwrap_or(PLANAR_CHUNKY as u32) as u16;
127    if planar != PLANAR_CHUNKY && planar != PLANAR_SEPARATE {
128        return Err(Error::invalid(format!(
129            "TIFF: PlanarConfiguration={planar} unknown (spec defines only 1 and 2)"
130        )));
131    }
132    // Spec page 38 ("PlanarConfiguration"): "If SamplesPerPixel is 1,
133    // PlanarConfiguration is irrelevant, and need not be included."
134    // A SPP=1 file tagged PlanarConfiguration=2 is well-formed and the
135    // on-disk layout is identical to chunky — collapse the two cases
136    // up front so the planar walker only has to handle the
137    // multi-component branch.
138    let planar = if samples_per_pixel == 1 {
139        PLANAR_CHUNKY
140    } else {
141        planar
142    };
143
144    let predictor = find(entries, TAG_PREDICTOR)
145        .map(|e| e.as_u32(bo))
146        .transpose()?
147        .unwrap_or(PREDICTOR_NONE as u32) as u16;
148    if predictor != PREDICTOR_NONE && predictor != PREDICTOR_HORIZONTAL {
149        return Err(Error::invalid(format!(
150            "TIFF: Predictor={predictor} not supported"
151        )));
152    }
153
154    // SampleFormat (TIFF 6.0 §SampleFormat tag 339, page 80). When
155    // present the field has one entry per sample, all of which must
156    // share an interpretation for this decoder's uniform-bit-depth
157    // assembly path to apply consistently. The spec lists four values:
158    // 1 (unsigned integer, the default), 2 (two's-complement signed
159    // integer), 3 (IEEE floating point) and 4 (undefined). The reader
160    // rule is: "If the SampleFormat field is present and the value is
161    // not 1, a Baseline TIFF reader that cannot handle the SampleFormat
162    // value must terminate the import process gracefully."
163    //
164    // This decoder routes value 1, value 4 (folded back to unsigned per
165    // the §SampleFormat note "A reader would typically treat an image
166    // with 'undefined' data as if the field were not present (i.e. as
167    // unsigned integer data)") and value 2 (two's-complement signed
168    // integer grayscale — see the grayscale build arms) through the
169    // integer assembly path; value 3 (IEEE floating point) is rejected
170    // because the byte interpretation is a non-integer numeric type the
171    // integer pipeline cannot render. An absent field defaults to
172    // unsigned per the §SampleFormat default-1 paragraph.
173    let sample_format = if let Some(sf_entry) = find(entries, TAG_SAMPLE_FORMAT) {
174        // The §SampleFormat header line "N = SamplesPerPixel" sets the
175        // expected count. Some writers under-state the count (one
176        // entry meant to apply to every sample); accept either the
177        // spec-required SamplesPerPixel-long array or a single-entry
178        // shorthand, but never a mismatched non-1 count.
179        let sf = sf_entry.as_u32_vec(bo)?;
180        if sf.len() != samples_per_pixel as usize && sf.len() != 1 {
181            return Err(Error::invalid(format!(
182                "TIFF: SampleFormat count {} != SamplesPerPixel {}",
183                sf.len(),
184                samples_per_pixel
185            )));
186        }
187        if !sf.iter().all(|&v| v == sf[0]) {
188            return Err(Error::Unsupported(
189                "TIFF: per-component SampleFormat values must be uniform".into(),
190            ));
191        }
192        let fmt = sf[0] as u16;
193        match fmt {
194            SAMPLE_FORMAT_UINT | SAMPLE_FORMAT_UNDEFINED => SAMPLE_FORMAT_UINT,
195            SAMPLE_FORMAT_SINT => SAMPLE_FORMAT_SINT,
196            SAMPLE_FORMAT_IEEE_FP => {
197                return Err(Error::Unsupported(
198                    "TIFF: SampleFormat=3 (IEEE floating-point) not supported; \
199                     §SampleFormat requires readers that cannot handle the value to \
200                     terminate gracefully"
201                        .into(),
202                ));
203            }
204            other => {
205                return Err(Error::invalid(format!(
206                    "TIFF: SampleFormat={other} unknown (spec defines 1..=4)"
207                )));
208            }
209        }
210    } else {
211        SAMPLE_FORMAT_UINT
212    };
213
214    // Orientation (TIFF 6.0 §Orientation tag 274, page 36). SHORT,
215    // N = 1, default 1. The spec defines eight values: 1 = the 0th
216    // row is the visual top and the 0th column is the visual
217    // left-hand side (the canonical "as-stored == as-displayed"
218    // layout); 2..=8 cover the remaining horizontal flip / vertical
219    // flip / 90° / 180° / 270° / transpose / antitranspose
220    // permutations a writer may declare. The spec page 36 closing
221    // note reads: "Default is 1. Support for orientations other than
222    // 1 is not a Baseline TIFF requirement." This decoder is one
223    // such Baseline-only reader — it surfaces pixels in storage
224    // order and does not rotate or mirror them, so the canonical
225    // value 1 is the only orientation it can honour without
226    // silently mis-rendering. Values 2..=8 are surfaced as precise
227    // typed errors rather than silently decoded as 1 (which would
228    // produce a correctly-coloured but geometrically-wrong image);
229    // value 0 and values ≥ 9 are surfaced as invalid-data errors
230    // because the spec lists 1..=8 only. An absent field defaults
231    // to 1 per the §Orientation "Default is 1" line.
232    if let Some(orient_entry) = find(entries, TAG_ORIENTATION) {
233        let orient = orient_entry.as_u32(bo)? as u16;
234        match orient {
235            1 => {
236                // Canonical "as-stored == as-displayed" — nothing
237                // to do; the rest of the decoder writes pixels in
238                // storage order which is also display order.
239            }
240            2..=8 => {
241                return Err(Error::Unsupported(format!(
242                    "TIFF: Orientation={orient} not supported; \
243                     §Orientation page 36 states 'Support for orientations \
244                     other than 1 is not a Baseline TIFF requirement'"
245                )));
246            }
247            other => {
248                return Err(Error::invalid(format!(
249                    "TIFF: Orientation={other} unknown (spec defines 1..=8)"
250                )));
251            }
252        }
253    }
254
255    // ResolutionUnit (TIFF 6.0 §"Physical Dimensions" / ResolutionUnit
256    // tag 296, page 18). SHORT, N = 1, "Default = 2 (inch)." Spec defines
257    // three values:
258    //
259    //   1 = No absolute unit of measurement (non-square aspect ratio
260    //       with no meaningful absolute dimensions).
261    //   2 = Inch.
262    //   3 = Centimeter.
263    //
264    // The on-disk pixel bytes are independent of the resolution
265    // metadata, so the decoder does not dispatch on this field — but a
266    // spec-conformant reader still benefits from rejecting malformed
267    // writers up front (rather than silently dropping the field).
268    // Absent → default 2 (inch) per spec; explicit 1 / 2 / 3 → accept;
269    // 0 / ≥ 4 → InvalidData because the spec lists 1..=3 only.
270    if let Some(unit_entry) = find(entries, TAG_RESOLUTION_UNIT) {
271        let unit = unit_entry.as_u32(bo)? as u16;
272        match unit {
273            RESOLUTION_UNIT_NONE | RESOLUTION_UNIT_INCH | RESOLUTION_UNIT_CENTIMETER => {
274                // Spec-defined; pixel decode does not depend on the
275                // resolution unit so nothing further to do.
276            }
277            other => {
278                return Err(Error::invalid(format!(
279                    "TIFF: ResolutionUnit={other} unknown (spec defines 1..=3)"
280                )));
281            }
282        }
283    }
284
285    // ExtraSamples (TIFF 6.0 §ExtraSamples tag 338, pages 31-32).
286    // SHORT, N = m. "Specifies that each pixel has m extra components
287    // whose interpretation is defined by one of the values listed
288    // below. When this field is used, the SamplesPerPixel field has a
289    // value greater than the PhotometricInterpretation field
290    // suggests." The spec example fixes the count arithmetic: "For
291    // example, full-color RGB data normally has SamplesPerPixel=3. If
292    // SamplesPerPixel is greater than 3, then the ExtraSamples field
293    // describes the meaning of the extra samples. If SamplesPerPixel
294    // is, say, 5 then ExtraSamples will contain 2 values, one for
295    // each extra sample." Per-value reader policy here:
296    //
297    //   0 (unspecified data) — the extra components carry no defined
298    //     meaning, so the color components render correctly without
299    //     them; the pixel paths skip the trailing extras.
300    //   1 (associated alpha, pre-multiplied color) — the stored color
301    //     components are pre-multiplied by the alpha component, so
302    //     dropping the alpha would present composited-against-black
303    //     colors as a fully-opaque image: a silently-wrong render.
304    //     Surfaced as a precise `Unsupported`, mirroring the
305    //     §Orientation policy of refusing to mis-render.
306    //   2 (unassociated alpha) — "transparency information that
307    //     logically exists independent of an image; it is commonly
308    //     called a soft matte." The color components are stored
309    //     straight (not pre-multiplied), so skipping the matte yields
310    //     the correctly-colored fully-opaque render.
311    //
312    // "The default is no extra samples" — an absent field changes
313    // nothing. Values ≥ 3 are surfaced as `InvalidData` because the
314    // spec lists 0..=2 only.
315    if let Some(es_entry) = find(entries, TAG_EXTRA_SAMPLES) {
316        let es = es_entry.as_u32_vec(bo)?;
317        // "By convention, extra components that are present must be
318        // stored as the 'last components' in each pixel" — so the
319        // leading `SamplesPerPixel − m` components must be exactly
320        // the color components the photometric calls for. §23 CIELab
321        // admits two layouts ("SamplesPerPixel - ExtraSamples: 3 for
322        // L*a*b*, 1 implies L* only, for monochrome data"), hence the
323        // slice of admissible color-component counts.
324        let color_counts: &[u16] = match photometric {
325            PHOTO_WHITE_IS_ZERO | PHOTO_BLACK_IS_ZERO | PHOTO_PALETTE | PHOTO_TRANSPARENCY_MASK => {
326                &[1]
327            }
328            PHOTO_RGB | PHOTO_YCBCR => &[3],
329            PHOTO_CMYK => &[4],
330            PHOTO_CIELAB => &[3, 1],
331            // Unknown photometric: leave the rejection to the
332            // photometric dispatch below, which names the value.
333            _ => &[],
334        };
335        if !color_counts.is_empty() {
336            let m = es.len() as u64;
337            let ok = color_counts
338                .iter()
339                .any(|&c| samples_per_pixel as u64 == c as u64 + m);
340            if !ok {
341                return Err(Error::invalid(format!(
342                    "TIFF: ExtraSamples count {m} does not leave a color-component \
343                     count photometric={photometric} defines \
344                     (SamplesPerPixel={samples_per_pixel})"
345                )));
346            }
347        }
348        for &v in &es {
349            if v == EXTRA_SAMPLE_UNSPECIFIED as u32 || v == EXTRA_SAMPLE_UNASSOCIATED_ALPHA as u32 {
350                // Skippable: the pixel paths drop the trailing extra
351                // components and render the color components.
352            } else if v == EXTRA_SAMPLE_ASSOCIATED_ALPHA as u32 {
353                return Err(Error::Unsupported(format!(
354                    "TIFF: ExtraSamples={v} (associated alpha, pre-multiplied color) \
355                     not supported; dropping the alpha component would mis-render \
356                     the pre-multiplied color components"
357                )));
358            } else {
359                return Err(Error::invalid(format!(
360                    "TIFF: ExtraSamples={v} unknown (spec defines 0..=2)"
361                )));
362            }
363        }
364    }
365
366    // ---- Tiles vs. strips ----
367    let bps_first = bits_per_sample[0];
368    if !bits_per_sample.iter().all(|&b| b == bps_first) {
369        return Err(Error::invalid(
370            "TIFF: per-channel BitsPerSample must be uniform in this build",
371        ));
372    }
373    if bps_first != 1 && bps_first != 4 && bps_first != 8 && bps_first != 16 {
374        return Err(Error::invalid(format!(
375            "TIFF: BitsPerSample={bps_first} not supported"
376        )));
377    }
378
379    // Extract CCITT/FillOrder-relevant IFD fields once.
380    //
381    // FillOrder (tag 266, TIFF 6.0 §FillOrder page 32): 1 = pixels
382    // with lower column values are in the high-order bits (default,
383    // canonical), 2 = pixels with lower column values are in the
384    // low-order bits (only meaningful when BitsPerSample=1 and the
385    // data is uncompressed or CCITT-compressed; spec says
386    // explicitly: "FillOrder = 2 should be used only when
387    // BitsPerSample = 1 and the data is either uncompressed or
388    // compressed using CCITT 1D or 2D compression").
389    let fill_order_raw = find(entries, TAG_FILL_ORDER)
390        .map(|e| e.as_u32(bo))
391        .transpose()?
392        .unwrap_or(1) as u16;
393    let fill_order = match fill_order_raw {
394        1 => FillOrder::MsbFirst,
395        2 => {
396            // Spec page 32: FillOrder=2 is only valid for BPS=1
397            // (uncompressed or CCITT-compressed). Reject other
398            // combinations rather than silently mis-decode.
399            let allowed_compression = matches!(
400                compression,
401                COMPRESSION_NONE
402                    | COMPRESSION_CCITT_HUFFMAN
403                    | COMPRESSION_CCITT_T4
404                    | COMPRESSION_CCITT_T6
405            );
406            if bps_first != 1 || !allowed_compression {
407                return Err(Error::invalid(format!(
408                    "TIFF: FillOrder=2 only valid for BitsPerSample=1 uncompressed/CCITT \
409                     (got bps={bps_first}, compression={compression})"
410                )));
411            }
412            FillOrder::LsbFirst
413        }
414        n => {
415            return Err(Error::invalid(format!(
416                "TIFF: FillOrder={n} unknown (spec defines only 1 and 2)"
417            )));
418        }
419    };
420    let t4_options = find(entries, TAG_T4_OPTIONS)
421        .map(|e| e.as_u32(bo))
422        .transpose()?
423        .unwrap_or(0);
424    let t6_options = find(entries, TAG_T6_OPTIONS)
425        .map(|e| e.as_u32(bo))
426        .transpose()?
427        .unwrap_or(0);
428
429    // JPEG-in-TIFF (Compression = 7) takes its own path. Each strip
430    // or tile is a freestanding JPEG datastream; merging the optional
431    // `JPEGTables` blob in front and feeding the result to the JPEG
432    // codec gives us decoded planes directly, so the strip / tile
433    // walker bypasses the chunky `pixel_buf` intermediate entirely.
434    #[cfg(feature = "registry")]
435    if compression == COMPRESSION_JPEG_NEW {
436        // TN2 "Special considerations for PlanarConfiguration 2":
437        // each image segment carries one component plane only, and
438        // chroma-subsampled JPEG segments must restate their
439        // dimensions in absolute pixel terms. Our JPEG path is
440        // chunky-only; reject planar=2 with a precise error so we
441        // don't silently mis-decode.
442        if planar == PLANAR_SEPARATE {
443            return Err(Error::Unsupported(
444                "TIFF/JPEG: PlanarConfiguration=2 (separate planes) not supported".into(),
445            ));
446        }
447        return decode_ifd_jpeg(
448            input,
449            entries,
450            bo,
451            width,
452            height,
453            samples_per_pixel,
454            bps_first,
455            photometric,
456        );
457    }
458    // Without the `registry` feature, JPEG-in-TIFF is unavailable —
459    // the JPEG codec lives in `oxideav-mjpeg`, which is gated on the
460    // same feature. Fail with a precise error rather than silently
461    // mis-decoding.
462    #[cfg(not(feature = "registry"))]
463    if compression == COMPRESSION_JPEG_NEW {
464        return Err(Error::Unsupported(
465            "TIFF: Compression=7 (JPEG-in-TIFF) requires the `registry` feature".into(),
466        ));
467    }
468    // The TIFF 6.0 §22 "old-style" JPEG (Compression=6) is officially
469    // deprecated by Tech Note 2 and we don't attempt it.
470    if compression == COMPRESSION_JPEG_OLD {
471        return Err(Error::Unsupported(
472            "TIFF: Compression=6 (old-style JPEG) is deprecated by TIFF Tech Note 2; \
473             writers should emit Compression=7 instead"
474                .into(),
475        ));
476    }
477
478    let pixel_buf = if find(entries, TAG_TILE_WIDTH).is_some() {
479        if planar == PLANAR_SEPARATE {
480            decode_tiles_planar(
481                input,
482                entries,
483                bo,
484                width,
485                height,
486                samples_per_pixel,
487                bps_first,
488                compression,
489                predictor,
490                t4_options,
491                t6_options,
492                fill_order,
493            )?
494        } else {
495            decode_tiles(
496                input,
497                entries,
498                bo,
499                width,
500                height,
501                samples_per_pixel,
502                bps_first,
503                compression,
504                predictor,
505                t4_options,
506                t6_options,
507                fill_order,
508            )?
509        }
510    } else if planar == PLANAR_SEPARATE {
511        decode_strips_planar(
512            input,
513            entries,
514            bo,
515            width,
516            height,
517            samples_per_pixel,
518            bps_first,
519            compression,
520            predictor,
521            t4_options,
522            t6_options,
523            fill_order,
524        )?
525    } else {
526        decode_strips(
527            input,
528            entries,
529            bo,
530            width,
531            height,
532            samples_per_pixel,
533            bps_first,
534            compression,
535            predictor,
536            t4_options,
537            t6_options,
538            fill_order,
539        )?
540    };
541
542    // Note on CCITT photometric handling: §10 / §11 describe the
543    // codec as "self-photometric in terms of white and black runs",
544    // and TIFF 6.0 says a BlackIsZero reader must "reverse the
545    // meaning of white and black when displaying". In practice
546    // every conformant reader treats the codec as bit-transparent
547    // on the wire: the encoder emits white runs for input bits
548    // of 0 and black runs for input bits of 1, irrespective of
549    // PhotometricInterpretation; the reader returns the raw
550    // 1-bit-per-pixel buffer untouched and lets the downstream
551    // bilevel-to-Gray8 conversion apply the photometric inversion
552    // (which `build_gray8_from_1bpp` already does via its `invert`
553    // argument). No extra inversion is needed here.
554
555    // SampleFormat = 2 (two's-complement signed integer, TIFF 6.0
556    // §SampleFormat page 80) is honoured for the single-channel
557    // grayscale photometrics at the integer widths this decoder
558    // assembles (8- and 16-bit BlackIsZero / WhiteIsZero) — the layout
559    // scientific and elevation TIFFs use. The on-disk samples span the
560    // signed range whose default extent §SampleFormat fixes at "the
561    // full range of the data type" (SMinSampleValue / SMaxSampleValue
562    // tags 340 / 341 default to the type bounds). Rendering signed
563    // samples on the codec's unsigned display planes uses the
564    // order-preserving offset-binary map: the signed minimum lands at
565    // 0 and the signed maximum at the unsigned ceiling (a sign-bit
566    // flip: 8-bit XOR 0x80, 16-bit XOR 0x8000), the bijective,
567    // monotone mapping that keeps relative brightness intact — the
568    // same "some conversion to a display range is required" latitude
569    // §23 grants the CIELab path. For any other photometric / width
570    // the integer-signed bytes have no single defensible display
571    // mapping in this build, so SampleFormat = 2 is refused there per
572    // the §SampleFormat "terminate gracefully" reader rule rather than
573    // silently mis-rendered.
574    if sample_format == SAMPLE_FORMAT_SINT {
575        let grayscale = matches!(photometric, PHOTO_BLACK_IS_ZERO | PHOTO_WHITE_IS_ZERO);
576        if !grayscale || samples_per_pixel != 1 || !(bps_first == 8 || bps_first == 16) {
577            return Err(Error::Unsupported(format!(
578                "TIFF: SampleFormat=2 (signed integer) supported only for 8-/16-bit \
579                 grayscale (photometric 0/1, SamplesPerPixel=1); got photometric={photometric} \
580                 samples_per_pixel={samples_per_pixel} bits_per_sample={bps_first}"
581            )));
582        }
583    }
584
585    // ---- Convert into a standard TiffPixelFormat ----
586    let (image, _pf) = match (photometric, samples_per_pixel, bps_first) {
587        (PHOTO_BLACK_IS_ZERO, 1, 1) | (PHOTO_WHITE_IS_ZERO, 1, 1) => {
588            let inv = photometric == PHOTO_WHITE_IS_ZERO;
589            let row_bytes = ((width as u64).div_ceil(8)) as usize;
590            (
591                build_gray8_from_1bpp(&pixel_buf, width, height, row_bytes, inv),
592                TiffPixelFormat::Gray8,
593            )
594        }
595        // PhotometricInterpretation = 4 (Transparency Mask), TIFF 6.0
596        // page 37: "This means that the image is used to define an
597        // irregularly shaped region of another image in the same TIFF
598        // file. SamplesPerPixel and BitsPerSample must be 1. PackBits
599        // compression is recommended. The 1-bits define the interior
600        // of the region; the 0-bits define the exterior of the region."
601        //
602        // The spec ties the mask's polarity to bit value (1 = interior /
603        // visible, 0 = exterior), independent of FillOrder or
604        // PhotometricInterpretation inversion. We expose it as a
605        // Gray8 plane where interior pixels are 0xFF and exterior
606        // pixels are 0x00 — i.e. the same byte layout a downstream
607        // compositor would multiply with the main image. Strip /
608        // tile / FillOrder / compression handling is identical to
609        // the BlackIsZero bilevel path, so we route through the
610        // same expander with `invert = false` (bit 1 -> 0xFF).
611        (PHOTO_TRANSPARENCY_MASK, 1, 1) => {
612            let row_bytes = ((width as u64).div_ceil(8)) as usize;
613            (
614                build_gray8_from_1bpp(&pixel_buf, width, height, row_bytes, false),
615                TiffPixelFormat::Gray8,
616            )
617        }
618        (PHOTO_BLACK_IS_ZERO, 1, 4) | (PHOTO_WHITE_IS_ZERO, 1, 4) => {
619            let inv = photometric == PHOTO_WHITE_IS_ZERO;
620            let row_bytes = ((width as u64).div_ceil(2)) as usize;
621            (
622                build_gray8_from_4bpp(&pixel_buf, width, height, row_bytes, inv),
623                TiffPixelFormat::Gray8,
624            )
625        }
626        (PHOTO_BLACK_IS_ZERO, 1, 8) | (PHOTO_WHITE_IS_ZERO, 1, 8) => {
627            let inv = photometric == PHOTO_WHITE_IS_ZERO;
628            let signed = sample_format == SAMPLE_FORMAT_SINT;
629            (
630                build_gray8(&pixel_buf, width, height, inv, signed),
631                TiffPixelFormat::Gray8,
632            )
633        }
634        (PHOTO_BLACK_IS_ZERO, 1, 16) | (PHOTO_WHITE_IS_ZERO, 1, 16) => {
635            let inv = photometric == PHOTO_WHITE_IS_ZERO;
636            let signed = sample_format == SAMPLE_FORMAT_SINT;
637            (
638                build_gray16le(&pixel_buf, width, height, bo, inv, signed),
639                TiffPixelFormat::Gray16Le,
640            )
641        }
642        (PHOTO_RGB, 3, 8) => (
643            build_rgb24(&pixel_buf, width, height),
644            TiffPixelFormat::Rgb24,
645        ),
646        (PHOTO_RGB, 3, 16) => (
647            build_rgb48le(&pixel_buf, width, height, bo),
648            TiffPixelFormat::Rgb48Le,
649        ),
650        (PHOTO_RGB, n, 8) if n >= 4 => {
651            // §ExtraSamples (pages 31-32): "By convention, extra
652            // components that are present must be stored as the
653            // 'last components' in each pixel" — the leading three
654            // components are the R, G, B triple and the trailing
655            // n − 3 extras are skipped. The §ExtraSamples inspection
656            // above has already rejected the associated-alpha case
657            // whose color components could not render verbatim.
658            (
659                build_rgb_from_n_chunky_8bit(&pixel_buf, width, height, n as usize),
660                TiffPixelFormat::Rgb24,
661            )
662        }
663        (PHOTO_PALETTE, 1, b @ (4 | 8)) => {
664            let cm = find(entries, TAG_COLOR_MAP)
665                .ok_or_else(|| Error::invalid("TIFF: palette image missing ColorMap"))?
666                .as_u32_vec(bo)?;
667            let palette = parse_colormap(&cm, b)?;
668            let row_bytes = if b == 8 {
669                width as usize
670            } else {
671                ((width as u64).div_ceil(2)) as usize
672            };
673            (
674                build_rgb24_from_palette(&pixel_buf, width, height, &palette, b, row_bytes),
675                TiffPixelFormat::Rgb24,
676            )
677        }
678        (PHOTO_CMYK, 4, 8) => (
679            build_rgb24_from_cmyk(&pixel_buf, width, height),
680            TiffPixelFormat::Rgb24,
681        ),
682        (PHOTO_YCBCR, 3, 8) => {
683            // Subsampling defaults: 2 horizontal / 2 vertical per
684            // TIFF 6.0 §22.
685            let (sh, sv) = match find(entries, TAG_YCBCR_SUBSAMPLING) {
686                Some(e) => {
687                    let v = e.as_u32_vec(bo)?;
688                    if v.len() < 2 {
689                        return Err(Error::invalid("TIFF: YCbCrSubSampling too short"));
690                    }
691                    (v[0] as u16, v[1] as u16)
692                }
693                None => (2, 2),
694            };
695            (
696                build_rgb24_from_ycbcr(&pixel_buf, width, height, sh, sv)?,
697                TiffPixelFormat::Rgb24,
698            )
699        }
700        // PhotometricInterpretation = 8 (1976 CIE L*a*b*), TIFF 6.0
701        // §23 "CIE L*a*b* Images" (page 110). Three 8-bit chunky
702        // samples per pixel: L* in 0..255 mapping linearly to the
703        // 0..100 perceptual-lightness scale, a* and b* as
704        // two's-complement signed 8-bit values in -128..127
705        // representing the red/green and yellow/blue chrominance
706        // channels (§23: "L* range is from 0 ... to 100 ... The
707        // a* and b* ranges will be represented as signed 8 bit
708        // values having the range -127 to +127"). The decoder
709        // colorimetrically converts via Lab -> XYZ (D65 reference
710        // white) -> linear NTSC RGB (§23's stated forward matrix,
711        // inverted analytically) -> sRGB-encoded 8-bit, matching
712        // the "(perfect reflecting diffuser ... D65 illumination)"
713        // reference white the spec mandates. This is a display-
714        // ready render path consistent with how the existing
715        // YCbCr and CMYK photometrics collapse to Rgb24.
716        (PHOTO_CIELAB, 3, 8) => (
717            build_rgb24_from_cielab(&pixel_buf, width, height),
718            TiffPixelFormat::Rgb24,
719        ),
720        // §23: "SamplesPerPixel - ExtraSamples: 3 for L*a*b*, 1
721        // implies L* only, for monochrome data". An L*-only CIELab
722        // image is a perceptual grayscale: the stored 0..255 byte
723        // maps linearly to L* 0..100, which we re-encode as
724        // gamma-corrected sRGB-luminance so the 8-bit output is
725        // ready for display.
726        (PHOTO_CIELAB, 1, 8) => (
727            build_gray8_from_cielab_l(&pixel_buf, width, height),
728            TiffPixelFormat::Gray8,
729        ),
730        (p, s, b) => {
731            return Err(Error::invalid(format!(
732                "TIFF: photometric={p} samples_per_pixel={s} bits_per_sample={b} not supported"
733            )))
734        }
735    };
736
737    Ok(image)
738}
739
740#[allow(clippy::too_many_arguments)]
741fn decode_strips(
742    input: &[u8],
743    entries: &[Entry],
744    bo: ByteOrder,
745    width: u32,
746    height: u32,
747    samples_per_pixel: u16,
748    bps_first: u16,
749    compression: u16,
750    predictor: u16,
751    t4_options: u32,
752    t6_options: u32,
753    fill_order: FillOrder,
754) -> Result<Vec<u8>> {
755    let rows_per_strip = find(entries, TAG_ROWS_PER_STRIP)
756        .map(|e| e.as_u32(bo))
757        .transpose()?
758        .unwrap_or(height); // default per spec is "the entire image is one strip"
759
760    let strip_offsets = find(entries, TAG_STRIP_OFFSETS)
761        .ok_or_else(|| Error::invalid("TIFF: missing StripOffsets"))?
762        .as_u64_vec(bo)?;
763    let strip_byte_counts = find(entries, TAG_STRIP_BYTE_COUNTS)
764        .ok_or_else(|| Error::invalid("TIFF: missing StripByteCounts"))?
765        .as_u64_vec(bo)?;
766    if strip_offsets.len() != strip_byte_counts.len() {
767        return Err(Error::invalid(
768            "TIFF: StripOffsets / StripByteCounts length mismatch",
769        ));
770    }
771
772    // Chroma-subsampled YCbCr (TIFF 6.0 §21, PlanarConfiguration = 1)
773    // stores data units rather than full-resolution per-pixel triples,
774    // so its on-disk byte budget per luma row is smaller than
775    // `width * SamplesPerPixel * bps`. Detect that layout up front and
776    // size the strips by the §21 data-unit geometry; everything else
777    // keeps the standard full-resolution row accounting. The
778    // JPEG-compressed YCbCr path never reaches here (each strip is a
779    // self-contained JPEG datastream decoded separately), and §14
780    // predictor / §"PlanarConfiguration"=2 are out of scope for the
781    // subsampled byte-aligned path.
782    let photometric = find(entries, TAG_PHOTOMETRIC_INTERPRETATION)
783        .map(|e| e.as_u32(bo))
784        .transpose()?
785        .unwrap_or(0);
786    let ycbcr_subsampling: Option<(usize, usize)> = if photometric == PHOTO_YCBCR as u32
787        && samples_per_pixel == 3
788        && bps_first == 8
789        && !matches!(compression, COMPRESSION_JPEG_OLD | COMPRESSION_JPEG_NEW)
790    {
791        let (sh, sv) = match find(entries, TAG_YCBCR_SUBSAMPLING) {
792            Some(e) => {
793                let v = e.as_u32_vec(bo)?;
794                if v.len() < 2 {
795                    return Err(Error::invalid("TIFF: YCbCrSubSampling too short"));
796                }
797                (v[0] as usize, v[1] as usize)
798            }
799            // §21 page 90 default is [2, 2].
800            None => (2, 2),
801        };
802        if sh == 0 || sv == 0 {
803            return Err(Error::invalid("TIFF: YCbCrSubSampling must be > 0"));
804        }
805        if (sh, sv) != (1, 1) {
806            Some((sh, sv))
807        } else {
808            None
809        }
810    } else {
811        None
812    };
813
814    // For sub-byte bit depths, rows are padded to byte boundaries
815    // per TIFF 6.0 spec.
816    let bits_per_row = (width as u64) * (samples_per_pixel as u64) * (bps_first as u64);
817    let row_bytes = bits_per_row.div_ceil(8) as usize;
818
819    // For subsampled YCbCr the per-strip byte budget is computed from
820    // data units (§21 page 93): each strip holds `rows / sv` data-unit
821    // rows of `width / sh` units, every unit `sh*sv + 2` bytes. The
822    // strip's RowsPerStrip is constrained by §21 page 90 to be an
823    // integer multiple of `sv`.
824    let ycbcr_strip_bytes = |rows: u32| -> usize {
825        if let Some((sh, sv)) = ycbcr_subsampling {
826            let block_w = (width as usize).div_ceil(sh);
827            let block_rows = (rows as usize).div_ceil(sv);
828            block_w * block_rows * (sh * sv + 2)
829        } else {
830            row_bytes * rows as usize
831        }
832    };
833
834    let buf_cap = if ycbcr_subsampling.is_some() {
835        ycbcr_strip_bytes(height)
836    } else {
837        row_bytes * height as usize
838    };
839    let mut pixel_buf: Vec<u8> = Vec::with_capacity(buf_cap);
840    let mut rows_done: u32 = 0;
841    for (i, (&offset, &byte_count)) in strip_offsets
842        .iter()
843        .zip(strip_byte_counts.iter())
844        .enumerate()
845    {
846        let start = offset as usize;
847        let end = start
848            .checked_add(byte_count as usize)
849            .ok_or_else(|| Error::invalid(format!("TIFF: strip {i} length overflow")))?;
850        if end > input.len() {
851            return Err(Error::invalid(format!("TIFF: strip {i} extends past EOF")));
852        }
853        let raw = &input[start..end];
854        let rows_this_strip = rows_per_strip.min(height - rows_done);
855        let expected = ycbcr_strip_bytes(rows_this_strip);
856        let ccitt = if matches!(
857            compression,
858            COMPRESSION_CCITT_HUFFMAN | COMPRESSION_CCITT_T4 | COMPRESSION_CCITT_T6
859        ) {
860            Some(CcittParams {
861                width,
862                rows: rows_this_strip,
863                fill: fill_order,
864                t4_options,
865                t6_options,
866            })
867        } else {
868            None
869        };
870        let decompressed = decompress_block(raw, expected, compression, ccitt)?;
871        if decompressed.len() < expected {
872            return Err(Error::invalid(format!(
873                "TIFF: strip {i} short after decompress: got {} bytes, expected {}",
874                decompressed.len(),
875                expected
876            )));
877        }
878        // Apply predictor per-row if requested.
879        let mut strip = decompressed[..expected].to_vec();
880        // Uncompressed bilevel data carried with FillOrder=2 has the
881        // bit order reversed in every byte; canonicalise to MSB-first
882        // here so the bilevel-to-Gray8 expander downstream sees the
883        // same layout regardless of FillOrder. CCITT-compressed data
884        // is already normalised inside `decode_ccitt`, so we only
885        // touch the Compression=None path.
886        if compression == COMPRESSION_NONE && fill_order == FillOrder::LsbFirst && bps_first == 1 {
887            reverse_bits_in_place(&mut strip);
888        }
889        if predictor == PREDICTOR_HORIZONTAL {
890            // §14 horizontal differencing operates on full-resolution
891            // sample rows; it is undefined over the §21 subsampled
892            // data-unit layout (where one packed row interleaves a
893            // 2-D Y block with single Cb/Cr samples). Reject rather
894            // than mis-apply it across the data-unit boundaries.
895            if ycbcr_subsampling.is_some() {
896                return Err(Error::invalid(
897                    "TIFF: Predictor=2 with chroma-subsampled YCbCr (TIFF 6.0 §21 data units) \
898                     is unsupported — §14 differencing has no defined meaning over the \
899                     packed data-unit layout",
900                ));
901            }
902            apply_horizontal_predictor(
903                &mut strip,
904                width as usize,
905                rows_this_strip as usize,
906                samples_per_pixel as usize,
907                bps_first as usize,
908                row_bytes,
909                bo,
910            )?;
911        }
912        pixel_buf.extend_from_slice(&strip);
913        rows_done += rows_this_strip;
914        if rows_done >= height {
915            break;
916        }
917    }
918    if rows_done < height {
919        return Err(Error::invalid("TIFF: strips did not cover full image"));
920    }
921    Ok(pixel_buf)
922}
923
924#[allow(clippy::too_many_arguments)]
925fn decode_tiles(
926    input: &[u8],
927    entries: &[Entry],
928    bo: ByteOrder,
929    width: u32,
930    height: u32,
931    samples_per_pixel: u16,
932    bps_first: u16,
933    compression: u16,
934    predictor: u16,
935    t4_options: u32,
936    t6_options: u32,
937    fill_order: FillOrder,
938) -> Result<Vec<u8>> {
939    let tile_w = find(entries, TAG_TILE_WIDTH)
940        .ok_or_else(|| Error::invalid("TIFF: missing TileWidth"))?
941        .as_u32(bo)?;
942    let tile_h = find(entries, TAG_TILE_LENGTH)
943        .ok_or_else(|| Error::invalid("TIFF: missing TileLength"))?
944        .as_u32(bo)?;
945    if tile_w == 0 || tile_h == 0 {
946        return Err(Error::invalid("TIFF: zero tile dimension"));
947    }
948    let tile_offsets = find(entries, TAG_TILE_OFFSETS)
949        .ok_or_else(|| Error::invalid("TIFF: missing TileOffsets"))?
950        .as_u64_vec(bo)?;
951    let tile_byte_counts = find(entries, TAG_TILE_BYTE_COUNTS)
952        .ok_or_else(|| Error::invalid("TIFF: missing TileByteCounts"))?
953        .as_u64_vec(bo)?;
954    if tile_offsets.len() != tile_byte_counts.len() {
955        return Err(Error::invalid(
956            "TIFF: TileOffsets / TileByteCounts length mismatch",
957        ));
958    }
959
960    let tiles_across = (width as u64).div_ceil(tile_w as u64) as u32;
961    let tiles_down = (height as u64).div_ceil(tile_h as u64) as u32;
962    let expected_tiles = (tiles_across as usize) * (tiles_down as usize);
963    if tile_offsets.len() != expected_tiles {
964        return Err(Error::invalid(format!(
965            "TIFF: TileOffsets length {} != expected {expected_tiles}",
966            tile_offsets.len()
967        )));
968    }
969
970    let bits_per_sample = bps_first as u64;
971    let bits_per_tile_row = (tile_w as u64) * (samples_per_pixel as u64) * bits_per_sample;
972    let tile_row_bytes = bits_per_tile_row.div_ceil(8) as usize;
973    let tile_size_bytes = tile_row_bytes * tile_h as usize;
974
975    let bits_per_image_row = (width as u64) * (samples_per_pixel as u64) * bits_per_sample;
976    let image_row_bytes = bits_per_image_row.div_ceil(8) as usize;
977    let mut out = vec![0u8; image_row_bytes * height as usize];
978
979    for ty in 0..tiles_down {
980        for tx in 0..tiles_across {
981            let idx = (ty * tiles_across + tx) as usize;
982            let off = tile_offsets[idx] as usize;
983            let bc = tile_byte_counts[idx] as usize;
984            let end = off
985                .checked_add(bc)
986                .ok_or_else(|| Error::invalid("TIFF: tile length overflow"))?;
987            if end > input.len() {
988                return Err(Error::invalid("TIFF: tile extends past EOF"));
989            }
990            let raw = &input[off..end];
991            let ccitt = if matches!(
992                compression,
993                COMPRESSION_CCITT_HUFFMAN | COMPRESSION_CCITT_T4 | COMPRESSION_CCITT_T6
994            ) {
995                Some(CcittParams {
996                    width: tile_w,
997                    rows: tile_h,
998                    fill: fill_order,
999                    t4_options,
1000                    t6_options,
1001                })
1002            } else {
1003                None
1004            };
1005            let mut tile = decompress_block(raw, tile_size_bytes, compression, ccitt)?;
1006            if tile.len() < tile_size_bytes {
1007                return Err(Error::invalid("TIFF: tile short after decompress"));
1008            }
1009            tile.truncate(tile_size_bytes);
1010            // Uncompressed bilevel tile path: see the strip-path
1011            // companion comment. Tiles are gated to bps_first % 8 ==
1012            // 0 above (see the explicit error a few lines below), so
1013            // in practice bps_first==1 + tiles is rejected, but we
1014            // keep the normalisation here for symmetry / safety.
1015            if compression == COMPRESSION_NONE
1016                && fill_order == FillOrder::LsbFirst
1017                && bps_first == 1
1018            {
1019                reverse_bits_in_place(&mut tile);
1020            }
1021            if predictor == PREDICTOR_HORIZONTAL {
1022                apply_horizontal_predictor(
1023                    &mut tile,
1024                    tile_w as usize,
1025                    tile_h as usize,
1026                    samples_per_pixel as usize,
1027                    bps_first as usize,
1028                    tile_row_bytes,
1029                    bo,
1030                )?;
1031            }
1032
1033            // Copy the visible portion of the tile into the output
1034            // buffer. Tiles at the right/bottom edge may extend past
1035            // the image; those samples are simply dropped.
1036            //
1037            // TIFF 6.0 §15 (page 67) requires `TileWidth` to be "a
1038            // multiple of 16", and §15 (page 66) states that within a
1039            // tile "each row of data in a tile is treated as a separate
1040            // 'scanline'", i.e. every tile row is independently padded
1041            // to a byte boundary (`tile_row_bytes` above). Together
1042            // these make every tile-column boundary in the destination
1043            // image row land on a byte boundary even at sub-byte bit
1044            // depths: column `tx` starts at bit
1045            // `tx · TileWidth · SamplesPerPixel · BitsPerSample`, and
1046            // `TileWidth` being a multiple of 16 makes that product a
1047            // multiple of 8 for any `BitsPerSample ∈ {1, 4}`. The
1048            // visible region of a tile row therefore copies as a whole
1049            // number of bytes starting at a byte-aligned destination
1050            // offset, exactly as for byte-aligned depths — the only
1051            // difference is that the visible width is measured in bits
1052            // and rounded up to bytes (the high bits of a partial-tile
1053            // trailing byte are padding the downstream expander, which
1054            // reads only `width` pixels per row, ignores).
1055            let bits_per_dst_origin =
1056                (tx as u64) * (tile_w as u64) * (samples_per_pixel as u64) * (bps_first as u64);
1057            if bits_per_dst_origin % 8 != 0 {
1058                // Cannot occur for `TileWidth` a multiple of 16, but
1059                // guard against a non-conformant writer rather than
1060                // panic on a misaligned slice.
1061                return Err(Error::invalid(
1062                    "TIFF: tile column boundary is not byte-aligned (TileWidth must be a \
1063                     multiple of 16 per TIFF 6.0 §15)",
1064                ));
1065            }
1066            let dst_origin_x = (bits_per_dst_origin / 8) as usize;
1067            let visible_w =
1068                ((width as i64) - (tx as i64) * (tile_w as i64)).min(tile_w as i64) as usize;
1069            let visible_h =
1070                ((height as i64) - (ty as i64) * (tile_h as i64)).min(tile_h as i64) as usize;
1071            let visible_row_bytes =
1072                ((visible_w as u64) * (samples_per_pixel as u64) * (bps_first as u64)).div_ceil(8)
1073                    as usize;
1074            let dst_origin_y = ty as usize * tile_h as usize;
1075            for r in 0..visible_h {
1076                let src_off = r * tile_row_bytes;
1077                let dst_off = (dst_origin_y + r) * image_row_bytes + dst_origin_x;
1078                out[dst_off..dst_off + visible_row_bytes]
1079                    .copy_from_slice(&tile[src_off..src_off + visible_row_bytes]);
1080            }
1081        }
1082    }
1083
1084    Ok(out)
1085}
1086
1087/// Decode strips for `PlanarConfiguration = 2` (separate component
1088/// planes), per TIFF 6.0 §"PlanarConfiguration" (page 38) and §22
1089/// "YCbCr Images / Storage Order".
1090///
1091/// Layout, per spec verbatim: "The values in StripOffsets and
1092/// StripByteCounts are then arranged as a 2-dimensional array, with
1093/// SamplesPerPixel rows and StripsPerImage columns. (All of the
1094/// columns for row 0 are stored first, followed by the columns of
1095/// row 1, and so on.)" So the entry arrays carry
1096/// `SamplesPerPixel * StripsPerImage` values; the first
1097/// `StripsPerImage` entries describe component 0 (e.g. Red), the next
1098/// `StripsPerImage` describe component 1 (Green), and so on.
1099///
1100/// Each plane decodes to a single-component image of the full
1101/// `width × height`. We then re-interleave the planes into chunky
1102/// `width × samples_per_pixel × height` ordering so the downstream
1103/// pixel-format conversion paths (which all assume chunky input)
1104/// don't need a planar duplicate.
1105#[allow(clippy::too_many_arguments)]
1106fn decode_strips_planar(
1107    input: &[u8],
1108    entries: &[Entry],
1109    bo: ByteOrder,
1110    width: u32,
1111    height: u32,
1112    samples_per_pixel: u16,
1113    bps_first: u16,
1114    compression: u16,
1115    predictor: u16,
1116    t4_options: u32,
1117    t6_options: u32,
1118    fill_order: FillOrder,
1119) -> Result<Vec<u8>> {
1120    if samples_per_pixel < 2 {
1121        // SPP=1 routes through the chunky walker by construction
1122        // (see `planar = if samples_per_pixel == 1 { ... }` in
1123        // `decode_ifd`), so reaching here with SPP<2 is a programming
1124        // error.
1125        return Err(Error::invalid(
1126            "TIFF: PlanarConfiguration=2 with SamplesPerPixel<2 is meaningless",
1127        ));
1128    }
1129    if bps_first % 8 != 0 {
1130        // Sub-byte component planes would need bit-level
1131        // interleaving in the chunky-rebuild step. None of the
1132        // photometrics that use planar layout (RGB, YCbCr, CMYK)
1133        // ship sub-byte samples in any deployed encoder, so the
1134        // value of supporting that case is low. Reject precisely
1135        // rather than silently mis-decode.
1136        return Err(Error::invalid(
1137            "TIFF: PlanarConfiguration=2 at sub-byte bit depths not supported",
1138        ));
1139    }
1140    let rows_per_strip = find(entries, TAG_ROWS_PER_STRIP)
1141        .map(|e| e.as_u32(bo))
1142        .transpose()?
1143        .unwrap_or(height);
1144    let strip_offsets = find(entries, TAG_STRIP_OFFSETS)
1145        .ok_or_else(|| Error::invalid("TIFF: missing StripOffsets"))?
1146        .as_u64_vec(bo)?;
1147    let strip_byte_counts = find(entries, TAG_STRIP_BYTE_COUNTS)
1148        .ok_or_else(|| Error::invalid("TIFF: missing StripByteCounts"))?
1149        .as_u64_vec(bo)?;
1150    if strip_offsets.len() != strip_byte_counts.len() {
1151        return Err(Error::invalid(
1152            "TIFF: StripOffsets / StripByteCounts length mismatch",
1153        ));
1154    }
1155    let strips_per_image = (height as u64).div_ceil(rows_per_strip as u64) as usize;
1156    let expected_entries = strips_per_image * samples_per_pixel as usize;
1157    if strip_offsets.len() != expected_entries {
1158        return Err(Error::invalid(format!(
1159            "TIFF: PlanarConfiguration=2 expects {expected_entries} strip entries \
1160             (SamplesPerPixel={samples_per_pixel} × StripsPerImage={strips_per_image}), got {}",
1161            strip_offsets.len()
1162        )));
1163    }
1164
1165    // Per-plane row stride: one component, full width, native bit
1166    // depth. `bits_per_sample[0] == bps_first` is enforced earlier
1167    // (the uniform-BPS check); each plane therefore carries the same
1168    // shape.
1169    let bits_per_plane_row = (width as u64) * (bps_first as u64);
1170    let plane_row_bytes = bits_per_plane_row.div_ceil(8) as usize;
1171
1172    let spp = samples_per_pixel as usize;
1173    let mut planes: Vec<Vec<u8>> = Vec::with_capacity(spp);
1174    for plane in 0..spp {
1175        let mut plane_buf: Vec<u8> = Vec::with_capacity(plane_row_bytes * height as usize);
1176        let mut rows_done: u32 = 0;
1177        let plane_start = plane * strips_per_image;
1178        for s in 0..strips_per_image {
1179            let i = plane_start + s;
1180            let offset = strip_offsets[i];
1181            let byte_count = strip_byte_counts[i];
1182            let start = offset as usize;
1183            let end = start.checked_add(byte_count as usize).ok_or_else(|| {
1184                Error::invalid(format!("TIFF: plane-{plane} strip {s} length overflow"))
1185            })?;
1186            if end > input.len() {
1187                return Err(Error::invalid(format!(
1188                    "TIFF: plane-{plane} strip {s} extends past EOF"
1189                )));
1190            }
1191            let raw = &input[start..end];
1192            let rows_this_strip = rows_per_strip.min(height - rows_done);
1193            let expected = plane_row_bytes * rows_this_strip as usize;
1194            let ccitt = if matches!(
1195                compression,
1196                COMPRESSION_CCITT_HUFFMAN | COMPRESSION_CCITT_T4 | COMPRESSION_CCITT_T6
1197            ) {
1198                Some(CcittParams {
1199                    width,
1200                    rows: rows_this_strip,
1201                    fill: fill_order,
1202                    t4_options,
1203                    t6_options,
1204                })
1205            } else {
1206                None
1207            };
1208            let decompressed = decompress_block(raw, expected, compression, ccitt)?;
1209            if decompressed.len() < expected {
1210                return Err(Error::invalid(format!(
1211                    "TIFF: plane-{plane} strip {s} short after decompress: got {}, expected {expected}",
1212                    decompressed.len()
1213                )));
1214            }
1215            let mut strip = decompressed[..expected].to_vec();
1216            if compression == COMPRESSION_NONE
1217                && fill_order == FillOrder::LsbFirst
1218                && bps_first == 1
1219            {
1220                reverse_bits_in_place(&mut strip);
1221            }
1222            if predictor == PREDICTOR_HORIZONTAL {
1223                // Spec §14: "If PlanarConfiguration is 2, there is
1224                // no problem. Differencing works the same as it does
1225                // for grayscale data." So we drive the predictor
1226                // with `samples = 1` regardless of the file's actual
1227                // SPP, because *within* a plane the data is a
1228                // single-component stream.
1229                apply_horizontal_predictor(
1230                    &mut strip,
1231                    width as usize,
1232                    rows_this_strip as usize,
1233                    1,
1234                    bps_first as usize,
1235                    plane_row_bytes,
1236                    bo,
1237                )?;
1238            }
1239            plane_buf.extend_from_slice(&strip);
1240            rows_done += rows_this_strip;
1241            if rows_done >= height {
1242                break;
1243            }
1244        }
1245        if rows_done < height {
1246            return Err(Error::invalid(format!(
1247                "TIFF: plane-{plane} strips did not cover full image"
1248            )));
1249        }
1250        planes.push(plane_buf);
1251    }
1252    interleave_planes(&planes, width, height, samples_per_pixel, bps_first)
1253}
1254
1255/// Decode tiles for `PlanarConfiguration = 2`, per TIFF 6.0
1256/// §"TileOffsets" (page 71): "TileOffsets length =
1257/// SamplesPerPixel * TilesPerImage for PlanarConfiguration = 2" with
1258/// "the offsets for the first component plane are stored first,
1259/// followed by all the offsets for the second component plane, and
1260/// so on." Same shape as the planar-strip walker; each plane decodes
1261/// into a single-component image, then we re-interleave.
1262#[allow(clippy::too_many_arguments)]
1263fn decode_tiles_planar(
1264    input: &[u8],
1265    entries: &[Entry],
1266    bo: ByteOrder,
1267    width: u32,
1268    height: u32,
1269    samples_per_pixel: u16,
1270    bps_first: u16,
1271    compression: u16,
1272    predictor: u16,
1273    t4_options: u32,
1274    t6_options: u32,
1275    fill_order: FillOrder,
1276) -> Result<Vec<u8>> {
1277    if samples_per_pixel < 2 {
1278        return Err(Error::invalid(
1279            "TIFF: PlanarConfiguration=2 with SamplesPerPixel<2 is meaningless",
1280        ));
1281    }
1282    if bps_first % 8 != 0 {
1283        return Err(Error::invalid(
1284            "TIFF: planar tiled images at sub-byte bit depths not supported",
1285        ));
1286    }
1287    let tile_w = find(entries, TAG_TILE_WIDTH)
1288        .ok_or_else(|| Error::invalid("TIFF: missing TileWidth"))?
1289        .as_u32(bo)?;
1290    let tile_h = find(entries, TAG_TILE_LENGTH)
1291        .ok_or_else(|| Error::invalid("TIFF: missing TileLength"))?
1292        .as_u32(bo)?;
1293    if tile_w == 0 || tile_h == 0 {
1294        return Err(Error::invalid("TIFF: zero tile dimension"));
1295    }
1296    let tile_offsets = find(entries, TAG_TILE_OFFSETS)
1297        .ok_or_else(|| Error::invalid("TIFF: missing TileOffsets"))?
1298        .as_u64_vec(bo)?;
1299    let tile_byte_counts = find(entries, TAG_TILE_BYTE_COUNTS)
1300        .ok_or_else(|| Error::invalid("TIFF: missing TileByteCounts"))?
1301        .as_u64_vec(bo)?;
1302    if tile_offsets.len() != tile_byte_counts.len() {
1303        return Err(Error::invalid(
1304            "TIFF: TileOffsets / TileByteCounts length mismatch",
1305        ));
1306    }
1307    let tiles_across = (width as u64).div_ceil(tile_w as u64) as u32;
1308    let tiles_down = (height as u64).div_ceil(tile_h as u64) as u32;
1309    let tiles_per_plane = (tiles_across as usize) * (tiles_down as usize);
1310    let expected_tiles = tiles_per_plane * samples_per_pixel as usize;
1311    if tile_offsets.len() != expected_tiles {
1312        return Err(Error::invalid(format!(
1313            "TIFF: PlanarConfiguration=2 expects {expected_tiles} tile entries \
1314             (SamplesPerPixel={samples_per_pixel} × TilesPerImage={tiles_per_plane}), got {}",
1315            tile_offsets.len()
1316        )));
1317    }
1318
1319    let sample_bytes = (bps_first / 8) as usize;
1320    let bits_per_plane_tile_row = (tile_w as u64) * (bps_first as u64);
1321    let tile_row_bytes = bits_per_plane_tile_row.div_ceil(8) as usize;
1322    let tile_size_bytes = tile_row_bytes * tile_h as usize;
1323    let bits_per_plane_image_row = (width as u64) * (bps_first as u64);
1324    let plane_image_row_bytes = bits_per_plane_image_row.div_ceil(8) as usize;
1325
1326    let spp = samples_per_pixel as usize;
1327    let mut planes: Vec<Vec<u8>> = Vec::with_capacity(spp);
1328    for plane in 0..spp {
1329        let mut plane_buf = vec![0u8; plane_image_row_bytes * height as usize];
1330        let plane_start = plane * tiles_per_plane;
1331        for ty in 0..tiles_down {
1332            for tx in 0..tiles_across {
1333                let idx = plane_start + (ty * tiles_across + tx) as usize;
1334                let off = tile_offsets[idx] as usize;
1335                let bc = tile_byte_counts[idx] as usize;
1336                let end = off.checked_add(bc).ok_or_else(|| {
1337                    Error::invalid(format!("TIFF: plane-{plane} tile length overflow"))
1338                })?;
1339                if end > input.len() {
1340                    return Err(Error::invalid(format!(
1341                        "TIFF: plane-{plane} tile extends past EOF"
1342                    )));
1343                }
1344                let raw = &input[off..end];
1345                let ccitt = if matches!(
1346                    compression,
1347                    COMPRESSION_CCITT_HUFFMAN | COMPRESSION_CCITT_T4 | COMPRESSION_CCITT_T6
1348                ) {
1349                    Some(CcittParams {
1350                        width: tile_w,
1351                        rows: tile_h,
1352                        fill: fill_order,
1353                        t4_options,
1354                        t6_options,
1355                    })
1356                } else {
1357                    None
1358                };
1359                let mut tile = decompress_block(raw, tile_size_bytes, compression, ccitt)?;
1360                if tile.len() < tile_size_bytes {
1361                    return Err(Error::invalid(format!(
1362                        "TIFF: plane-{plane} tile short after decompress"
1363                    )));
1364                }
1365                tile.truncate(tile_size_bytes);
1366                if compression == COMPRESSION_NONE
1367                    && fill_order == FillOrder::LsbFirst
1368                    && bps_first == 1
1369                {
1370                    reverse_bits_in_place(&mut tile);
1371                }
1372                if predictor == PREDICTOR_HORIZONTAL {
1373                    apply_horizontal_predictor(
1374                        &mut tile,
1375                        tile_w as usize,
1376                        tile_h as usize,
1377                        1,
1378                        bps_first as usize,
1379                        tile_row_bytes,
1380                        bo,
1381                    )?;
1382                }
1383                let visible_w =
1384                    ((width as i64) - (tx as i64) * (tile_w as i64)).min(tile_w as i64) as usize;
1385                let visible_h =
1386                    ((height as i64) - (ty as i64) * (tile_h as i64)).min(tile_h as i64) as usize;
1387                let visible_row_bytes = visible_w * sample_bytes;
1388                let dst_origin_x = tx as usize * tile_w as usize * sample_bytes;
1389                let dst_origin_y = ty as usize * tile_h as usize;
1390                for r in 0..visible_h {
1391                    let src_off = r * tile_row_bytes;
1392                    let dst_off = (dst_origin_y + r) * plane_image_row_bytes + dst_origin_x;
1393                    plane_buf[dst_off..dst_off + visible_row_bytes]
1394                        .copy_from_slice(&tile[src_off..src_off + visible_row_bytes]);
1395                }
1396            }
1397        }
1398        planes.push(plane_buf);
1399    }
1400    interleave_planes(&planes, width, height, samples_per_pixel, bps_first)
1401}
1402
1403/// Re-interleave per-component planes into chunky pixel order. The
1404/// caller has validated that every plane is `width * height *
1405/// (bps/8)` bytes and that `bps` is a multiple of 8. The output is
1406/// `width * height * samples_per_pixel * (bps/8)` bytes in
1407/// component-0, component-1, …, component-(spp-1), component-0,
1408/// component-1, … sequence (which is what the downstream chunky
1409/// converters consume).
1410fn interleave_planes(
1411    planes: &[Vec<u8>],
1412    width: u32,
1413    height: u32,
1414    samples_per_pixel: u16,
1415    bps_first: u16,
1416) -> Result<Vec<u8>> {
1417    let sample_bytes = (bps_first / 8) as usize;
1418    let spp = samples_per_pixel as usize;
1419    let pixels = (width as usize) * (height as usize);
1420    let plane_bytes = pixels * sample_bytes;
1421    for (i, p) in planes.iter().enumerate() {
1422        if p.len() != plane_bytes {
1423            return Err(Error::invalid(format!(
1424                "TIFF: plane {i} has {} bytes, expected {plane_bytes}",
1425                p.len()
1426            )));
1427        }
1428    }
1429    let mut out = vec![0u8; pixels * spp * sample_bytes];
1430    for px in 0..pixels {
1431        for (c, plane) in planes.iter().enumerate() {
1432            let src = &plane[px * sample_bytes..(px + 1) * sample_bytes];
1433            let dst_off = (px * spp + c) * sample_bytes;
1434            out[dst_off..dst_off + sample_bytes].copy_from_slice(src);
1435        }
1436    }
1437    Ok(out)
1438}
1439
1440/// Parameters needed for CCITT compression schemes (2 / 3 / 4).
1441/// Carries the per-block geometry plus the codec-shape options
1442/// decoded out of the IFD (FillOrder + T4Options + T6Options).
1443#[derive(Debug, Clone, Copy)]
1444struct CcittParams {
1445    width: u32,
1446    rows: u32,
1447    fill: FillOrder,
1448    t4_options: u32,
1449    t6_options: u32,
1450}
1451
1452fn decompress_block(
1453    raw: &[u8],
1454    expected: usize,
1455    compression: u16,
1456    ccitt: Option<CcittParams>,
1457) -> Result<Vec<u8>> {
1458    match compression {
1459        COMPRESSION_NONE => Ok(raw.to_vec()),
1460        COMPRESSION_PACKBITS => unpack_packbits(raw, expected),
1461        COMPRESSION_LZW => unpack_lzw(raw, expected),
1462        COMPRESSION_DEFLATE_ADOBE => unpack_deflate(raw, expected),
1463        // Compression=50000 (Zstandard, de-facto registry extension —
1464        // trace doc `docs/image/tiff/tiff-zstd-compression-50000.md`).
1465        // Structurally identical to Deflate: one self-contained codec
1466        // stream per strip / tile over the post-predictor bytes, so
1467        // the predictor reversal and photometric assembly downstream
1468        // of this dispatch apply unchanged.
1469        COMPRESSION_ZSTD => unpack_zstd(raw, expected),
1470        COMPRESSION_CCITT_HUFFMAN => {
1471            let p = ccitt
1472                .ok_or_else(|| Error::invalid("TIFF: CCITT compression requires CcittParams"))?;
1473            decode_ccitt(raw, p.width, p.rows, CcittVariant::ModifiedHuffman, p.fill)
1474        }
1475        COMPRESSION_CCITT_T4 => {
1476            let p = ccitt
1477                .ok_or_else(|| Error::invalid("TIFF: CCITT-T4 compression requires CcittParams"))?;
1478            // T4Options bit 1 ("uncompressed mode allowed") is a writer
1479            // capability flag, not a per-stream requirement: the
1480            // uncompressed-mode segments are self-delimiting (entrance
1481            // code `0000001111`, exit code `0000001T`…) and the 2-D
1482            // READ decoder handles them inline whether or not the bit
1483            // is set, so we no longer reject the flag. The decode of an
1484            // actual uncompressed segment lives in
1485            // `ccitt::decode_uncompressed_segment` (Table 5/T.4).
1486            let eol_byte_aligned = p.t4_options & T4OPT_EOL_BYTE_ALIGNED != 0;
1487            let variant = if p.t4_options & T4OPT_2D_CODING != 0 {
1488                CcittVariant::T4TwoD { eol_byte_aligned }
1489            } else {
1490                CcittVariant::T4OneD { eol_byte_aligned }
1491            };
1492            decode_ccitt(raw, p.width, p.rows, variant, p.fill)
1493        }
1494        COMPRESSION_CCITT_T6 => {
1495            let p = ccitt
1496                .ok_or_else(|| Error::invalid("TIFF: CCITT-T6 compression requires CcittParams"))?;
1497            // T6Options (tag 293): only bit 1 ("uncompressed mode
1498            // allowed") is defined per TIFF 6.0 §11; bit 0 is reserved
1499            // and all higher bits are undefined. Bit 1 is a
1500            // writer-capability hint, not a per-stream requirement —
1501            // uncompressed-mode segments are self-delimiting and the
1502            // 2-D READ decoder (`ccitt::decode_uncompressed_segment`,
1503            // Table 4/T.6 §2.3.1) handles them inline regardless of the
1504            // bit, so we accept the flag. We still reject any *other*
1505            // bit being set rather than silently ignore a tag the spec
1506            // doesn't define.
1507            if p.t6_options & !T6OPT_UNCOMPRESSED != 0 {
1508                return Err(Error::invalid(format!(
1509                    "TIFF: T6Options has undefined bits set (0x{:x}); only bit 1 is defined",
1510                    p.t6_options
1511                )));
1512            }
1513            decode_ccitt(raw, p.width, p.rows, CcittVariant::T6, p.fill)
1514        }
1515        other => Err(Error::invalid(format!(
1516            "TIFF: Compression={other} not supported"
1517        ))),
1518    }
1519}
1520
1521fn decode_bits_per_sample(
1522    entry: Option<&crate::ifd::Entry>,
1523    bo: ByteOrder,
1524    spp: u16,
1525) -> Result<Vec<u16>> {
1526    match entry {
1527        None => Ok(vec![1; spp as usize]), // default per spec
1528        Some(e) => {
1529            if e.count as u16 != spp {
1530                return Err(Error::invalid(format!(
1531                    "TIFF: BitsPerSample count {} != SamplesPerPixel {}",
1532                    e.count, spp
1533                )));
1534            }
1535            let v = e.as_u32_vec(bo)?;
1536            Ok(v.into_iter().map(|b| b as u16).collect())
1537        }
1538    }
1539}
1540
1541/// Spec Section 14: subtract previous pixel of the same component.
1542/// Implementation note: when `SamplesPerPixel > 1`, the offset
1543/// between the source and the destination is `SamplesPerPixel`
1544/// components, NOT 1; per-component differencing is what compressors
1545/// and decompressors do (so red is differenced against red, green
1546/// against green, etc.).
1547fn apply_horizontal_predictor(
1548    buf: &mut [u8],
1549    width: usize,
1550    rows: usize,
1551    samples: usize,
1552    bps: usize,
1553    row_bytes: usize,
1554    bo: ByteOrder,
1555) -> Result<()> {
1556    if width == 0 || rows == 0 {
1557        return Ok(());
1558    }
1559    match bps {
1560        8 => {
1561            for r in 0..rows {
1562                let row = &mut buf[r * row_bytes..r * row_bytes + width * samples];
1563                for x in samples..(width * samples) {
1564                    row[x] = row[x].wrapping_add(row[x - samples]);
1565                }
1566            }
1567        }
1568        16 => {
1569            for r in 0..rows {
1570                let row = &mut buf[r * row_bytes..r * row_bytes + width * samples * 2];
1571                let pixels = width * samples;
1572                // Convert in place. Read using current byte order
1573                // (in-file), accumulate, write back same way.
1574                for x in samples..pixels {
1575                    let cur_off = x * 2;
1576                    let prev_off = (x - samples) * 2;
1577                    let cur = bo.read_u16(&row[cur_off..cur_off + 2]);
1578                    let prev = bo.read_u16(&row[prev_off..prev_off + 2]);
1579                    let new = cur.wrapping_add(prev);
1580                    let bytes = match bo {
1581                        ByteOrder::Little => new.to_le_bytes(),
1582                        ByteOrder::Big => new.to_be_bytes(),
1583                    };
1584                    row[cur_off] = bytes[0];
1585                    row[cur_off + 1] = bytes[1];
1586                }
1587            }
1588        }
1589        4 => {
1590            // Spec Section 14: expand to 8-bit, difference, repack.
1591            for r in 0..rows {
1592                let row_off = r * row_bytes;
1593                // Decode row to 8-bit per nibble.
1594                let mut tmp: Vec<u8> = Vec::with_capacity(width * samples);
1595                for x in 0..(width * samples) {
1596                    let byte = buf[row_off + x / 2];
1597                    let n = if x & 1 == 0 { byte >> 4 } else { byte & 0x0F };
1598                    tmp.push(n);
1599                }
1600                for x in samples..(width * samples) {
1601                    tmp[x] = tmp[x].wrapping_add(tmp[x - samples]) & 0x0F;
1602                }
1603                // Repack.
1604                for (x, b) in tmp.iter().enumerate() {
1605                    let off = row_off + x / 2;
1606                    if x & 1 == 0 {
1607                        buf[off] = (buf[off] & 0x0F) | ((b & 0x0F) << 4);
1608                    } else {
1609                        buf[off] = (buf[off] & 0xF0) | (b & 0x0F);
1610                    }
1611                }
1612            }
1613        }
1614        _ => {
1615            return Err(Error::invalid(format!(
1616                "TIFF: predictor at bits_per_sample={bps} unsupported"
1617            )))
1618        }
1619    }
1620    Ok(())
1621}
1622
1623// ---------------------------------------------------------------------------
1624// Pixel-format conversions
1625// ---------------------------------------------------------------------------
1626
1627fn build_gray8(src: &[u8], w: u32, h: u32, invert: bool, signed: bool) -> TiffImage {
1628    let stride = w as usize;
1629    let mut data = src[..stride * h as usize].to_vec();
1630    // SampleFormat = 2: map two's-complement signed bytes onto the
1631    // unsigned display range with the order-preserving offset-binary
1632    // transform (signed min -> 0, signed max -> 255). For 8-bit that
1633    // is exactly a sign-bit flip (XOR 0x80). This runs before the
1634    // WhiteIsZero polarity inversion, which operates on the unsigned
1635    // display value.
1636    if signed {
1637        for b in data.iter_mut() {
1638            *b ^= 0x80;
1639        }
1640    }
1641    if invert {
1642        for b in data.iter_mut() {
1643            *b = 255 - *b;
1644        }
1645    }
1646    TiffImage {
1647        width: w,
1648        height: h,
1649        pixel_format: TiffPixelFormat::Gray8,
1650        planes: vec![TiffPlane { stride, data }],
1651    }
1652}
1653
1654fn build_gray8_from_4bpp(src: &[u8], w: u32, h: u32, row_bytes: usize, invert: bool) -> TiffImage {
1655    let stride = w as usize;
1656    let mut data = Vec::with_capacity(stride * h as usize);
1657    for y in 0..h as usize {
1658        let row = &src[y * row_bytes..y * row_bytes + row_bytes];
1659        for x in 0..w as usize {
1660            let byte = row[x / 2];
1661            let n = if x & 1 == 0 { byte >> 4 } else { byte & 0x0F };
1662            // Scale 4-bit into 8-bit: replicate nibble (0xF -> 0xFF).
1663            let v = (n << 4) | n;
1664            data.push(if invert { 255 - v } else { v });
1665        }
1666    }
1667    TiffImage {
1668        width: w,
1669        height: h,
1670        pixel_format: TiffPixelFormat::Gray8,
1671        planes: vec![TiffPlane { stride, data }],
1672    }
1673}
1674
1675fn build_gray8_from_1bpp(src: &[u8], w: u32, h: u32, row_bytes: usize, invert: bool) -> TiffImage {
1676    let stride = w as usize;
1677    let mut data = Vec::with_capacity(stride * h as usize);
1678    for y in 0..h as usize {
1679        let row = &src[y * row_bytes..y * row_bytes + row_bytes];
1680        for x in 0..w as usize {
1681            let byte = row[x / 8];
1682            let bit = (byte >> (7 - (x % 8))) & 1;
1683            // BlackIsZero: 0=black=0, 1=white=255. Invert flips.
1684            let v = if bit == 1 { 255 } else { 0 };
1685            data.push(if invert { 255 - v } else { v });
1686        }
1687    }
1688    TiffImage {
1689        width: w,
1690        height: h,
1691        pixel_format: TiffPixelFormat::Gray8,
1692        planes: vec![TiffPlane { stride, data }],
1693    }
1694}
1695
1696fn build_gray16le(
1697    src: &[u8],
1698    w: u32,
1699    h: u32,
1700    bo: ByteOrder,
1701    invert: bool,
1702    signed: bool,
1703) -> TiffImage {
1704    let stride = w as usize * 2;
1705    let n = (w * h) as usize;
1706    let mut data = Vec::with_capacity(stride * h as usize);
1707    for i in 0..n {
1708        let mut v = bo.read_u16(&src[i * 2..i * 2 + 2]);
1709        // SampleFormat = 2: offset-binary map (signed min -> 0, signed
1710        // max -> 0xFFFF) is a 16-bit sign-bit flip (XOR 0x8000),
1711        // applied before the WhiteIsZero polarity inversion.
1712        if signed {
1713            v ^= 0x8000;
1714        }
1715        let v = if invert { 0xFFFF - v } else { v };
1716        data.extend_from_slice(&v.to_le_bytes());
1717    }
1718    TiffImage {
1719        width: w,
1720        height: h,
1721        pixel_format: TiffPixelFormat::Gray16Le,
1722        planes: vec![TiffPlane { stride, data }],
1723    }
1724}
1725
1726fn build_rgb24(src: &[u8], w: u32, h: u32) -> TiffImage {
1727    let stride = w as usize * 3;
1728    let data = src[..stride * h as usize].to_vec();
1729    TiffImage {
1730        width: w,
1731        height: h,
1732        pixel_format: TiffPixelFormat::Rgb24,
1733        planes: vec![TiffPlane { stride, data }],
1734    }
1735}
1736
1737fn build_rgb_from_n_chunky_8bit(src: &[u8], w: u32, h: u32, n: usize) -> TiffImage {
1738    let stride = w as usize * 3;
1739    let mut data = Vec::with_capacity(stride * h as usize);
1740    for y in 0..h as usize {
1741        let row = &src[y * w as usize * n..(y + 1) * w as usize * n];
1742        for x in 0..w as usize {
1743            data.push(row[x * n]);
1744            data.push(row[x * n + 1]);
1745            data.push(row[x * n + 2]);
1746        }
1747    }
1748    TiffImage {
1749        width: w,
1750        height: h,
1751        pixel_format: TiffPixelFormat::Rgb24,
1752        planes: vec![TiffPlane { stride, data }],
1753    }
1754}
1755
1756fn build_rgb48le(src: &[u8], w: u32, h: u32, bo: ByteOrder) -> TiffImage {
1757    let stride = w as usize * 6;
1758    let pixels = (w * h) as usize;
1759    let mut data = Vec::with_capacity(stride * h as usize);
1760    for i in 0..pixels {
1761        let off = i * 6;
1762        for c in 0..3 {
1763            let v = bo.read_u16(&src[off + c * 2..off + c * 2 + 2]);
1764            data.extend_from_slice(&v.to_le_bytes());
1765        }
1766    }
1767    TiffImage {
1768        width: w,
1769        height: h,
1770        pixel_format: TiffPixelFormat::Rgb48Le,
1771        planes: vec![TiffPlane { stride, data }],
1772    }
1773}
1774
1775fn parse_colormap(words: &[u32], bps: u16) -> Result<Vec<[u8; 3]>> {
1776    // ColorMap: 3 * 2^BitsPerSample SHORTs. All red first, then
1777    // green, then blue. 0..=65535 represent the channel intensity.
1778    let entries = 1usize << bps;
1779    if words.len() < 3 * entries {
1780        return Err(Error::invalid(format!(
1781            "TIFF: ColorMap has {} entries, expected {}",
1782            words.len(),
1783            3 * entries
1784        )));
1785    }
1786    let mut out = Vec::with_capacity(entries);
1787    for i in 0..entries {
1788        let r = (words[i] >> 8) as u8;
1789        let g = (words[entries + i] >> 8) as u8;
1790        let b = (words[2 * entries + i] >> 8) as u8;
1791        out.push([r, g, b]);
1792    }
1793    Ok(out)
1794}
1795
1796fn build_rgb24_from_palette(
1797    src: &[u8],
1798    w: u32,
1799    h: u32,
1800    palette: &[[u8; 3]],
1801    bps: u16,
1802    row_bytes: usize,
1803) -> TiffImage {
1804    let stride = w as usize * 3;
1805    let mut data = Vec::with_capacity(stride * h as usize);
1806    for y in 0..h as usize {
1807        let row = &src[y * row_bytes..y * row_bytes + row_bytes];
1808        for x in 0..w as usize {
1809            let idx = match bps {
1810                8 => row[x] as usize,
1811                4 => {
1812                    let byte = row[x / 2];
1813                    (if x & 1 == 0 { byte >> 4 } else { byte & 0x0F }) as usize
1814                }
1815                _ => 0,
1816            };
1817            let p = palette.get(idx).copied().unwrap_or([0, 0, 0]);
1818            data.push(p[0]);
1819            data.push(p[1]);
1820            data.push(p[2]);
1821        }
1822    }
1823    TiffImage {
1824        width: w,
1825        height: h,
1826        pixel_format: TiffPixelFormat::Rgb24,
1827        planes: vec![TiffPlane { stride, data }],
1828    }
1829}
1830
1831/// CMYK (TIFF 6.0 §16): 4 chunky bytes per pixel ordered C, M, Y, K
1832/// where each component is the *complement* of its dye coverage
1833/// (255 = no dye). Convert into the customary additive RGB the
1834/// crate emits: R = (1-C)(1-K), G = (1-M)(1-K), B = (1-Y)(1-K), all
1835/// scaled to 8-bit. This matches `tiffinfo`'s reference rendering
1836/// and is what callers expect for screen display.
1837fn build_rgb24_from_cmyk(src: &[u8], w: u32, h: u32) -> TiffImage {
1838    let stride = w as usize * 3;
1839    let pixels = (w * h) as usize;
1840    let mut data = Vec::with_capacity(stride * h as usize);
1841    for i in 0..pixels {
1842        let off = i * 4;
1843        let c = src[off] as u32;
1844        let m = src[off + 1] as u32;
1845        let y = src[off + 2] as u32;
1846        let k = src[off + 3] as u32;
1847        // Inversion is in the spec: stored values are the *amount*
1848        // of dye, so larger = darker. Compose multiplicatively.
1849        let r = ((255 - c) * (255 - k) / 255) as u8;
1850        let g = ((255 - m) * (255 - k) / 255) as u8;
1851        let b = ((255 - y) * (255 - k) / 255) as u8;
1852        data.push(r);
1853        data.push(g);
1854        data.push(b);
1855    }
1856    TiffImage {
1857        width: w,
1858        height: h,
1859        pixel_format: TiffPixelFormat::Rgb24,
1860        planes: vec![TiffPlane { stride, data }],
1861    }
1862}
1863
1864/// YCbCr → RGB conversion per TIFF 6.0 §22 / ITU-R BT.601 (the
1865/// classical SDTV coefficients TIFF lists as the default reference).
1866/// Subsampling: only chunky `(sh, sv)` configurations are supported
1867/// here. The on-disk layout in chunky mode is documented in §22 as
1868/// "data unit" ordering: each block is (sh*sv) Y samples followed
1869/// by 1 Cb and 1 Cr sample. We re-tile that into per-pixel YCbCr
1870/// then apply the matrix.
1871fn build_rgb24_from_ycbcr(src: &[u8], w: u32, h: u32, sh: u16, sv: u16) -> Result<TiffImage> {
1872    if sh == 0 || sv == 0 {
1873        return Err(Error::invalid("TIFF: YCbCrSubSampling must be > 0"));
1874    }
1875    if !matches!(
1876        (sh, sv),
1877        (1, 1) | (2, 1) | (2, 2) | (1, 2) | (4, 1) | (4, 2)
1878    ) {
1879        return Err(Error::invalid(format!(
1880            "TIFF: YCbCrSubSampling=({sh},{sv}) not supported"
1881        )));
1882    }
1883    let block_w = (w as usize).div_ceil(sh as usize);
1884    let block_h = (h as usize).div_ceil(sv as usize);
1885    let block_size_bytes = (sh as usize) * (sv as usize) + 2; // Y*Y + Cb + Cr
1886    let expected = block_w * block_h * block_size_bytes;
1887    if src.len() < expected {
1888        return Err(Error::invalid(format!(
1889            "TIFF/YCbCr: pixel buffer too small (got {}, need {expected})",
1890            src.len()
1891        )));
1892    }
1893
1894    // Decode block by block, splatting Cb/Cr to each Y sample's
1895    // position. Pixels outside (w, h) are dropped.
1896    let mut data = vec![0u8; (w as usize) * 3 * h as usize];
1897    for by in 0..block_h {
1898        for bx in 0..block_w {
1899            let block_off = (by * block_w + bx) * block_size_bytes;
1900            let cb = src[block_off + (sh as usize) * (sv as usize)] as i32;
1901            let cr = src[block_off + (sh as usize) * (sv as usize) + 1] as i32;
1902            for sy in 0..(sv as usize) {
1903                for sx in 0..(sh as usize) {
1904                    let y_val = src[block_off + sy * (sh as usize) + sx] as i32;
1905                    let px = bx * (sh as usize) + sx;
1906                    let py = by * (sv as usize) + sy;
1907                    if px >= w as usize || py >= h as usize {
1908                        continue;
1909                    }
1910                    let (r, g, b) = ycbcr_to_rgb(y_val, cb, cr);
1911                    let dst = (py * (w as usize) + px) * 3;
1912                    data[dst] = r;
1913                    data[dst + 1] = g;
1914                    data[dst + 2] = b;
1915                }
1916            }
1917        }
1918    }
1919    Ok(TiffImage {
1920        width: w,
1921        height: h,
1922        pixel_format: TiffPixelFormat::Rgb24,
1923        planes: vec![TiffPlane {
1924            stride: w as usize * 3,
1925            data,
1926        }],
1927    })
1928}
1929
1930/// ITU-R BT.601 inverse-matrix YCbCr → RGB with the canonical
1931/// rounded integer coefficients (off-by-one differences from
1932/// floating-point reference impls are within tolerance).
1933fn ycbcr_to_rgb(y: i32, cb: i32, cr: i32) -> (u8, u8, u8) {
1934    let cb = cb - 128;
1935    let cr = cr - 128;
1936    // Coefficients × 65536 (Q16) for the BT.601 conversion. TIFF
1937    // 6.0 §22 default `YCbCrCoefficients` are exactly the BT.601
1938    // luma weights {0.299, 0.587, 0.114} that yield this matrix.
1939    let r = y + ((91881 * cr + 32768) >> 16);
1940    let g = y - ((22554 * cb + 46802 * cr + 32768) >> 16);
1941    let b = y + ((116130 * cb + 32768) >> 16);
1942    (clamp_u8(r), clamp_u8(g), clamp_u8(b))
1943}
1944
1945fn clamp_u8(v: i32) -> u8 {
1946    v.clamp(0, 255) as u8
1947}
1948
1949// ---------------------------------------------------------------------------
1950// CIELab (PhotometricInterpretation = 8) decode, per TIFF 6.0 §23.
1951// ---------------------------------------------------------------------------
1952
1953/// Decode a 3-sample CIELab strip/tile buffer into a packed Rgb24
1954/// [`TiffImage`] suitable for display.
1955///
1956/// On-disk layout per §23 page 110: 8-bit chunky L*, a*, b* triples
1957/// with L* unsigned 0..255 ↔ 0..100 and a*/b* two's-complement signed
1958/// 8-bit values in -128..127. The "reference white for this data type
1959/// is the perfect reflecting diffuser" with "D65 illumination", so we
1960/// convert via the Lab → XYZ formulas given on page 110 and the
1961/// inverse of §23's stated NTSC tristimulus matrix to recover linear
1962/// RGB. A standard sRGB-style gamma curve then maps the linear values
1963/// into a display-ready 8-bit byte. This is the same shape the
1964/// existing CMYK and YCbCr photometric paths take (decode produces
1965/// Rgb24 ready for compositing).
1966fn build_rgb24_from_cielab(src: &[u8], w: u32, h: u32) -> TiffImage {
1967    let stride = w as usize * 3;
1968    let pixels = (w as usize) * (h as usize);
1969    let mut data = Vec::with_capacity(stride * h as usize);
1970    for triple in src.chunks_exact(3).take(pixels) {
1971        // L* in 0..255 maps linearly to 0..100 per §23 ("Dividing
1972        // the 0-100 range of L* into 256 levels").
1973        let l_byte = triple[0] as f64;
1974        // a*, b* per §23: "signed 8 bit values having the range
1975        // -127 to +127". We accept the full two's-complement byte
1976        // range (-128..127) — values at the extremes are within
1977        // spec tolerance.
1978        let a_signed = triple[1] as i8 as f64;
1979        let b_signed = triple[2] as i8 as f64;
1980        let l = l_byte * (100.0 / 255.0);
1981        let (r, g, b) = cielab_to_rgb_byte(l, a_signed, b_signed);
1982        data.push(r);
1983        data.push(g);
1984        data.push(b);
1985    }
1986    TiffImage {
1987        width: w,
1988        height: h,
1989        pixel_format: TiffPixelFormat::Rgb24,
1990        planes: vec![TiffPlane { stride, data }],
1991    }
1992}
1993
1994/// Decode a 1-sample CIELab L*-only buffer into a Gray8 [`TiffImage`].
1995///
1996/// §23 page 110 ("Usage of other Fields"): "SamplesPerPixel -
1997/// ExtraSamples: 3 for L*a*b*, 1 implies L* only, for monochrome
1998/// data". The byte is the perceptual lightness on the 0..100 scale;
1999/// we re-encode it as sRGB-luminance so the gray level matches what
2000/// the 3-sample CIELab render produces for a chromatically-neutral
2001/// pixel (a* = b* = 0).
2002fn build_gray8_from_cielab_l(src: &[u8], w: u32, h: u32) -> TiffImage {
2003    let stride = w as usize;
2004    let pixels = (w as usize) * (h as usize);
2005    let mut data = Vec::with_capacity(pixels);
2006    for &byte in src.iter().take(pixels) {
2007        let l = byte as f64 * (100.0 / 255.0);
2008        // For a chromatically-neutral CIELab pixel (a* = b* = 0)
2009        // the Lab → XYZ formula collapses to Y = f(L*) (X and Z
2010        // are proportional with fx = fy = fz). Run the same
2011        // lightness curve as the 3-sample path so a* = b* = 0
2012        // CIELab pixels in either layout produce the same gray.
2013        let y_lin = lab_l_to_y_linear(l);
2014        data.push(linear_to_srgb_byte(y_lin));
2015    }
2016    TiffImage {
2017        width: w,
2018        height: h,
2019        pixel_format: TiffPixelFormat::Gray8,
2020        planes: vec![TiffPlane { stride, data }],
2021    }
2022}
2023
2024/// CIELab triple to display Rgb24 byte, per TIFF 6.0 §23 + the
2025/// inverse of §23's stated forward NTSC matrix.
2026///
2027/// Steps:
2028/// 1. Lab → XYZ via the inverse of the §23 page 110 formulas. The
2029///    reference white is the perfect reflecting diffuser at D65
2030///    (Xn = 0.95047, Yn = 1.00000, Zn = 1.08883 — the CIE 1931 2°
2031///    Standard Observer values for D65 the spec implies on page
2032///    111: "Generally, D65 illumination is used and a perfect
2033///    reflecting diffuser is used for the reference white").
2034/// 2. XYZ → linear RGB via the analytic inverse of the NTSC
2035///    tristimulus matrix the spec prints on page 111:
2036///    X = 0.6070 R + 0.1740 G + 0.2000 B,
2037///    Y = 0.2990 R + 0.5870 G + 0.1140 B,
2038///    Z = 0.0000 R + 0.0660 G + 1.1110 B.
2039///    Inverting algebraically (cofactor expansion, det ≈
2040///    0.337438) gives the row-major coefficients hard-coded
2041///    below. They are arithmetic facts derived from the spec's
2042///    matrix only — no external impl was consulted.
2043/// 3. Linear RGB → 8-bit display RGB via the standard sRGB OETF
2044///    so the result is ready for a contemporary screen. The
2045///    spec's "Converting from CIELAB to RGB" caveat (page 111)
2046///    explicitly leaves the linear-to-display step to the
2047///    implementer; gamma encoding is the universally-applicable
2048///    choice and matches the render the rest of the crate
2049///    produces from RGB / CMYK / YCbCr.
2050fn cielab_to_rgb_byte(l_star: f64, a_star: f64, b_star: f64) -> (u8, u8, u8) {
2051    // ---- Lab -> XYZ (D65 white) ----
2052    // CIE 1931 2° Standard Observer D65 white in the spec's
2053    // normalised "1.0 = perfect reflecting diffuser" form.
2054    const XN: f64 = 0.95047;
2055    const YN: f64 = 1.00000;
2056    const ZN: f64 = 1.08883;
2057
2058    // §23 page 110:
2059    //   L* = 116 (Y/Yn)^(1/3) - 16            (Y/Yn > 0.008856)
2060    //   L* = 903.3 (Y/Yn)                     (Y/Yn <= 0.008856)
2061    //   a* = 500 [(X/Xn)^(1/3) - (Y/Yn)^(1/3)]
2062    //   b* = 200 [(Y/Yn)^(1/3) - (Z/Zn)^(1/3)]
2063    // Inverting: let fy = (L*+16)/116; if fy^3 > 0.008856 then
2064    // Y/Yn = fy^3 else Y/Yn = L*/903.3. Likewise for X, Z via
2065    // fx = a*/500 + fy and fz = fy - b*/200, applying the low-light
2066    // branch when the cube is at or below 0.008856 (the spec's
2067    // threshold). The cube root inverse of the low-light leg
2068    // 7.787*F + 16/116 is (f - 16/116)/7.787.
2069    let fy = (l_star + 16.0) / 116.0;
2070    let fx = a_star / 500.0 + fy;
2071    let fz = fy - b_star / 200.0;
2072
2073    let y_yn = if l_star > 7.999625 {
2074        // Boundary: L* = 116 * 0.008856^(1/3) - 16 ≈ 7.99963; above
2075        // this the cubic branch applies.
2076        fy.powi(3)
2077    } else {
2078        l_star / 903.3
2079    };
2080    let fx3 = fx.powi(3);
2081    let x_xn = if fx3 > 0.008856 {
2082        fx3
2083    } else {
2084        (fx - 16.0 / 116.0) / 7.787
2085    };
2086    let fz3 = fz.powi(3);
2087    let z_zn = if fz3 > 0.008856 {
2088        fz3
2089    } else {
2090        (fz - 16.0 / 116.0) / 7.787
2091    };
2092
2093    let x = x_xn * XN;
2094    let y = y_yn * YN;
2095    let z = z_zn * ZN;
2096
2097    // ---- XYZ -> linear RGB ----
2098    // Inverse of §23 NTSC matrix:
2099    //   [ 0.6070  0.1740  0.2000 ]
2100    //   [ 0.2990  0.5870  0.1140 ]
2101    //   [ 0.0000  0.0660  1.1110 ]
2102    // Determinant (cofactor expansion along row 0):
2103    //    0.6070*(0.5870*1.1110 - 0.1140*0.0660)
2104    //   -0.1740*(0.2990*1.1110 - 0.1140*0.0000)
2105    //   +0.2000*(0.2990*0.0660 - 0.5870*0.0000)
2106    //  = 0.6070*0.644633 - 0.1740*0.332189 + 0.2000*0.019734
2107    //  ≈ 0.337438145
2108    // Adjugate row-major / determinant gives the inverse below.
2109    // The constants are arithmetic facts derived from the spec
2110    // matrix only — no external impl was consulted.
2111    let r_lin = 1.9103738257 * x - 0.5337689371 * y - 0.2891315088 * z;
2112    let g_lin = -0.9844441268 * x + 1.9985203510 * y - 0.0278510303 * z;
2113    let b_lin = 0.0584818293 * x - 0.1187239812 * y + 0.9017445257 * z;
2114
2115    (
2116        linear_to_srgb_byte(r_lin),
2117        linear_to_srgb_byte(g_lin),
2118        linear_to_srgb_byte(b_lin),
2119    )
2120}
2121
2122/// Lab L* → linear Y under D65 reference white, used by the
2123/// L*-only render path. Same inverse-cube-root step as
2124/// [`cielab_to_rgb_byte`].
2125fn lab_l_to_y_linear(l_star: f64) -> f64 {
2126    if l_star > 7.999625 {
2127        ((l_star + 16.0) / 116.0).powi(3)
2128    } else {
2129        l_star / 903.3
2130    }
2131}
2132
2133/// Linear-light value in [0, 1] → 8-bit sRGB display byte using the
2134/// canonical sRGB OETF (the published IEC 61966-2-1 piecewise
2135/// curve). Out-of-gamut values are clamped to [0, 255].
2136fn linear_to_srgb_byte(v: f64) -> u8 {
2137    let c = v.clamp(0.0, 1.0);
2138    // sRGB encoding: linear-to-display compander. The piecewise
2139    // breakpoint at 0.0031308 maps to ~0.04045 on the display side.
2140    let encoded = if c <= 0.0031308 {
2141        12.92 * c
2142    } else {
2143        1.055 * c.powf(1.0 / 2.4) - 0.055
2144    };
2145    (encoded * 255.0 + 0.5).clamp(0.0, 255.0) as u8
2146}
2147
2148// ---------------------------------------------------------------------------
2149// JPEG-in-TIFF (Compression = 7) decode, per TIFF Tech Note 2.
2150// ---------------------------------------------------------------------------
2151
2152/// Decode a JPEG-in-TIFF IFD (`Compression = 7`) into a [`TiffImage`].
2153/// Lives behind the `registry` feature gate because it reaches into
2154/// `oxideav-mjpeg`'s `Decoder` trait surface.
2155#[cfg(feature = "registry")]
2156#[allow(clippy::too_many_arguments)]
2157fn decode_ifd_jpeg(
2158    input: &[u8],
2159    entries: &[Entry],
2160    bo: ByteOrder,
2161    width: u32,
2162    height: u32,
2163    samples_per_pixel: u16,
2164    bps_first: u16,
2165    photometric: u16,
2166) -> Result<TiffImage> {
2167    // TN2: SOFn precision must equal BitsPerSample, and 8-bit is the
2168    // only baseline-mandatory precision. Reject other depths up-front
2169    // so the JPEG decoder doesn't have to.
2170    if bps_first != 8 {
2171        return Err(Error::Unsupported(format!(
2172            "TIFF/JPEG: BitsPerSample={bps_first} (only 8-bit is supported in this build)"
2173        )));
2174    }
2175    // TN2 explicitly forbids palette (3) and transparency-mask (4)
2176    // photometrics with Compression=7. Reject any photometric we
2177    // don't have a render path for.
2178    let want_planes = match (photometric, samples_per_pixel) {
2179        (PHOTO_BLACK_IS_ZERO, 1) | (PHOTO_WHITE_IS_ZERO, 1) => 1,
2180        (PHOTO_RGB, 3) => 3,
2181        (PHOTO_YCBCR, 3) => 3,
2182        // CMYK JPEG-in-TIFF: mjpeg packs the 4 components into one
2183        // plane (stride = width × 4) and consumes any Adobe APP14
2184        // marker before handing the bytes back. Per TN2: "A
2185        // JPEG-compressed TIFF file will typically have
2186        // PhotometricInterpretation = YCbCr ... unless the source
2187        // data was grayscale or CMYK" — CMYK is an explicitly
2188        // permitted photometric for Compression=7.
2189        (PHOTO_CMYK, 4) => 1,
2190        (p, s) => {
2191            return Err(Error::invalid(format!(
2192                "TIFF/JPEG: photometric={p} samples_per_pixel={s} not supported"
2193            )));
2194        }
2195    };
2196    let _ = want_planes;
2197
2198    // Optional JPEGTables blob (tag 347). Per TN2 it's type
2199    // UNDEFINED, which the IFD parser keeps as raw bytes already; we
2200    // only need to slice it out.
2201    let tables: Option<&[u8]> = find(entries, TAG_JPEG_TABLES).map(|e| e.data.as_slice());
2202
2203    // Set up the destination buffer in the *final* output format the
2204    // crate emits for this photometric. Currently:
2205    //   - PHOTO_BLACK_IS_ZERO / WHITE_IS_ZERO  →  Gray8
2206    //   - PHOTO_RGB / PHOTO_YCBCR / PHOTO_CMYK →  Rgb24
2207    //   (CMYK is collapsed to Rgb24 by the same additive-RGB
2208    //    conversion the uncompressed CMYK path uses — see
2209    //    `build_rgb24_from_cmyk` / `composite_cmyk_to_rgb`.)
2210    let (pixel_format, dst_row_stride, dst_size) = match photometric {
2211        PHOTO_BLACK_IS_ZERO | PHOTO_WHITE_IS_ZERO => (
2212            TiffPixelFormat::Gray8,
2213            width as usize,
2214            width as usize * height as usize,
2215        ),
2216        PHOTO_RGB | PHOTO_YCBCR | PHOTO_CMYK => (
2217            TiffPixelFormat::Rgb24,
2218            width as usize * 3,
2219            width as usize * 3 * height as usize,
2220        ),
2221        _ => unreachable!("photometric vetted above"),
2222    };
2223    let mut dst = vec![0u8; dst_size];
2224
2225    let invert = photometric == PHOTO_WHITE_IS_ZERO;
2226    let want_yuv = photometric == PHOTO_YCBCR;
2227
2228    // Strip vs tile dispatch (mirrors the non-JPEG path).
2229    if find(entries, TAG_TILE_WIDTH).is_some() {
2230        decode_ifd_jpeg_tiles(
2231            input,
2232            entries,
2233            bo,
2234            width,
2235            height,
2236            photometric,
2237            tables,
2238            invert,
2239            want_yuv,
2240            &mut dst,
2241            dst_row_stride,
2242        )?;
2243    } else {
2244        decode_ifd_jpeg_strips(
2245            input,
2246            entries,
2247            bo,
2248            width,
2249            height,
2250            photometric,
2251            tables,
2252            invert,
2253            want_yuv,
2254            &mut dst,
2255            dst_row_stride,
2256        )?;
2257    }
2258
2259    Ok(TiffImage {
2260        width,
2261        height,
2262        pixel_format,
2263        planes: vec![TiffPlane {
2264            stride: dst_row_stride,
2265            data: dst,
2266        }],
2267    })
2268}
2269
2270#[cfg(feature = "registry")]
2271#[allow(clippy::too_many_arguments)]
2272fn decode_ifd_jpeg_strips(
2273    input: &[u8],
2274    entries: &[Entry],
2275    bo: ByteOrder,
2276    width: u32,
2277    height: u32,
2278    photometric: u16,
2279    tables: Option<&[u8]>,
2280    invert: bool,
2281    want_yuv: bool,
2282    dst: &mut [u8],
2283    dst_row_stride: usize,
2284) -> Result<()> {
2285    use crate::jpeg::decode_segment;
2286
2287    let rows_per_strip = find(entries, TAG_ROWS_PER_STRIP)
2288        .map(|e| e.as_u32(bo))
2289        .transpose()?
2290        .unwrap_or(height);
2291    let strip_offsets = find(entries, TAG_STRIP_OFFSETS)
2292        .ok_or_else(|| Error::invalid("TIFF: missing StripOffsets"))?
2293        .as_u64_vec(bo)?;
2294    let strip_byte_counts = find(entries, TAG_STRIP_BYTE_COUNTS)
2295        .ok_or_else(|| Error::invalid("TIFF: missing StripByteCounts"))?
2296        .as_u64_vec(bo)?;
2297    if strip_offsets.len() != strip_byte_counts.len() {
2298        return Err(Error::invalid(
2299            "TIFF/JPEG: StripOffsets / StripByteCounts length mismatch",
2300        ));
2301    }
2302
2303    let mut rows_done: u32 = 0;
2304    for (i, (&offset, &byte_count)) in strip_offsets
2305        .iter()
2306        .zip(strip_byte_counts.iter())
2307        .enumerate()
2308    {
2309        let start = offset as usize;
2310        let end = start
2311            .checked_add(byte_count as usize)
2312            .ok_or_else(|| Error::invalid(format!("TIFF: strip {i} length overflow")))?;
2313        if end > input.len() {
2314            return Err(Error::invalid(format!("TIFF: strip {i} extends past EOF")));
2315        }
2316        let raw = &input[start..end];
2317        let rows_this_strip = rows_per_strip.min(height - rows_done);
2318
2319        let seg = decode_segment(tables, raw, width, rows_this_strip, photometric)?;
2320        composite_segment(
2321            &seg,
2322            width,
2323            rows_this_strip,
2324            dst,
2325            dst_row_stride,
2326            0,
2327            rows_done,
2328            invert,
2329            want_yuv,
2330            photometric,
2331        )?;
2332        rows_done += rows_this_strip;
2333        if rows_done >= height {
2334            break;
2335        }
2336    }
2337    if rows_done < height {
2338        return Err(Error::invalid("TIFF/JPEG: strips did not cover full image"));
2339    }
2340    Ok(())
2341}
2342
2343#[cfg(feature = "registry")]
2344#[allow(clippy::too_many_arguments)]
2345fn decode_ifd_jpeg_tiles(
2346    input: &[u8],
2347    entries: &[Entry],
2348    bo: ByteOrder,
2349    width: u32,
2350    height: u32,
2351    photometric: u16,
2352    tables: Option<&[u8]>,
2353    invert: bool,
2354    want_yuv: bool,
2355    dst: &mut [u8],
2356    dst_row_stride: usize,
2357) -> Result<()> {
2358    use crate::jpeg::decode_segment;
2359
2360    let tile_w = find(entries, TAG_TILE_WIDTH)
2361        .ok_or_else(|| Error::invalid("TIFF: missing TileWidth"))?
2362        .as_u32(bo)?;
2363    let tile_h = find(entries, TAG_TILE_LENGTH)
2364        .ok_or_else(|| Error::invalid("TIFF: missing TileLength"))?
2365        .as_u32(bo)?;
2366    if tile_w == 0 || tile_h == 0 {
2367        return Err(Error::invalid("TIFF: zero tile dimension"));
2368    }
2369    let tile_offsets = find(entries, TAG_TILE_OFFSETS)
2370        .ok_or_else(|| Error::invalid("TIFF: missing TileOffsets"))?
2371        .as_u64_vec(bo)?;
2372    let tile_byte_counts = find(entries, TAG_TILE_BYTE_COUNTS)
2373        .ok_or_else(|| Error::invalid("TIFF: missing TileByteCounts"))?
2374        .as_u64_vec(bo)?;
2375    if tile_offsets.len() != tile_byte_counts.len() {
2376        return Err(Error::invalid(
2377            "TIFF/JPEG: TileOffsets / TileByteCounts length mismatch",
2378        ));
2379    }
2380    let tiles_across = (width as u64).div_ceil(tile_w as u64) as u32;
2381    let tiles_down = (height as u64).div_ceil(tile_h as u64) as u32;
2382    let expected_tiles = (tiles_across as usize) * (tiles_down as usize);
2383    if tile_offsets.len() != expected_tiles {
2384        return Err(Error::invalid(format!(
2385            "TIFF/JPEG: TileOffsets length {} != expected {expected_tiles}",
2386            tile_offsets.len()
2387        )));
2388    }
2389
2390    for ty in 0..tiles_down {
2391        for tx in 0..tiles_across {
2392            let idx = (ty * tiles_across + tx) as usize;
2393            let off = tile_offsets[idx] as usize;
2394            let bc = tile_byte_counts[idx] as usize;
2395            let end = off
2396                .checked_add(bc)
2397                .ok_or_else(|| Error::invalid("TIFF/JPEG: tile length overflow"))?;
2398            if end > input.len() {
2399                return Err(Error::invalid("TIFF/JPEG: tile extends past EOF"));
2400            }
2401            let raw = &input[off..end];
2402            let seg = decode_segment(tables, raw, tile_w, tile_h, photometric)?;
2403            let visible_w = ((width as i64) - (tx as i64) * (tile_w as i64))
2404                .min(tile_w as i64)
2405                .max(0) as u32;
2406            let visible_h = ((height as i64) - (ty as i64) * (tile_h as i64))
2407                .min(tile_h as i64)
2408                .max(0) as u32;
2409            composite_segment(
2410                &seg,
2411                visible_w,
2412                visible_h,
2413                dst,
2414                dst_row_stride,
2415                tx * tile_w,
2416                ty * tile_h,
2417                invert,
2418                want_yuv,
2419                photometric,
2420            )?;
2421        }
2422    }
2423    Ok(())
2424}
2425
2426/// Composite one decoded JPEG segment into the destination buffer
2427/// using the format-appropriate path.
2428#[cfg(feature = "registry")]
2429#[allow(clippy::too_many_arguments)]
2430fn composite_segment(
2431    seg: &crate::jpeg::JpegSegment,
2432    visible_w: u32,
2433    visible_h: u32,
2434    dst: &mut [u8],
2435    dst_row_stride: usize,
2436    dst_x: u32,
2437    dst_y: u32,
2438    invert: bool,
2439    want_yuv: bool,
2440    photometric: u16,
2441) -> Result<()> {
2442    use crate::jpeg::{
2443        composite_cmyk_to_rgb, composite_gray, composite_rgb_packed, composite_rgb_planar,
2444        composite_yuv_to_rgb, JpegPixelFormat,
2445    };
2446    match seg.pixel_format {
2447        JpegPixelFormat::Gray8 => composite_gray(
2448            seg,
2449            visible_w,
2450            visible_h,
2451            dst,
2452            dst_row_stride,
2453            dst_x,
2454            dst_y,
2455            invert,
2456        ),
2457        JpegPixelFormat::Yuv444P
2458        | JpegPixelFormat::Yuv422P
2459        | JpegPixelFormat::Yuv420P
2460        | JpegPixelFormat::Yuv411P => {
2461            if !want_yuv && photometric != PHOTO_RGB {
2462                return Err(Error::invalid(format!(
2463                    "TIFF/JPEG: YUV-output JPEG segment but TIFF photometric={photometric}"
2464                )));
2465            }
2466            // YCbCr photometric → matrix to RGB. PHOTO_RGB with a
2467            // YUV-output JPEG segment is theoretically possible if a
2468            // writer mistakenly attached `Sf` factors implying chroma
2469            // subsampling but kept the photometric tag at 2; we treat
2470            // that as a writer error.
2471            if want_yuv {
2472                composite_yuv_to_rgb(seg, visible_w, visible_h, dst, dst_row_stride, dst_x, dst_y)
2473            } else {
2474                composite_rgb_planar(seg, visible_w, visible_h, dst, dst_row_stride, dst_x, dst_y)
2475            }
2476        }
2477        JpegPixelFormat::Rgb24 => {
2478            if photometric != PHOTO_RGB {
2479                return Err(Error::invalid(format!(
2480                    "TIFF/JPEG: RGB-output JPEG but TIFF photometric={photometric}"
2481                )));
2482            }
2483            composite_rgb_planar(seg, visible_w, visible_h, dst, dst_row_stride, dst_x, dst_y)
2484        }
2485        JpegPixelFormat::Rgb24Packed => {
2486            if photometric != PHOTO_RGB {
2487                return Err(Error::invalid(format!(
2488                    "TIFF/JPEG: packed-RGB-output JPEG but TIFF photometric={photometric}"
2489                )));
2490            }
2491            composite_rgb_packed(seg, visible_w, visible_h, dst, dst_row_stride, dst_x, dst_y)
2492        }
2493        JpegPixelFormat::Cmyk8 => {
2494            if photometric != PHOTO_CMYK {
2495                return Err(Error::invalid(format!(
2496                    "TIFF/JPEG: CMYK-output JPEG but TIFF photometric={photometric}"
2497                )));
2498            }
2499            composite_cmyk_to_rgb(seg, visible_w, visible_h, dst, dst_row_stride, dst_x, dst_y)
2500        }
2501    }
2502}