Skip to main content

oxideav_tiff/
encoder.rs

1//! TIFF 6.0 encoder — single-IFD or multi-page (multi-IFD chain),
2//! little-endian on-disk byte order ("II"), classic 32-bit offsets or
3//! BigTIFF 64-bit offsets.
4//!
5//! The encoder targets the same baseline our decoder reads:
6//!
7//! * Photometric: WhiteIsZero (1-bit bilevel), BlackIsZero (greyscale
8//!   8/16-bit), RGB (8-bit), Palette (8-bit indexed)
9//! * Compression: 1 None, 2 CCITT Modified Huffman, 3 CCITT T.4 1-D
10//!   and 2-D (with optional T4Options bit 2 byte-aligned EOLs),
11//!   4 CCITT T.6, 5 LZW, 8 Deflate, 32773 PackBits, 50000 Zstandard
12//!   (de-facto registry extension; trace doc
13//!   `docs/image/tiff/tiff-zstd-compression-50000.md`)
14//! * Strip layout — a single strip for chunky pages, or one strip per
15//!   component plane for `PlanarConfiguration = 2` pages (see
16//!   [`EncodePage::planar`])
17//! * Single-IFD or multi-IFD chain via [`encode_tiff_multi`]
18//! * Variant: classic TIFF (8-byte header, magic 42, 32-bit offsets,
19//!   12-byte IFD entries) or BigTIFF (16-byte header, magic 43,
20//!   8-byte offset-bytesize + reserved, 64-bit offsets, 20-byte IFD
21//!   entries, 8-byte inline value/offset slot, LONG8/IFD8 types per
22//!   the Adobe Pagemaker 6.0 BigTIFF design), selectable via
23//!   [`EncodePage::bigtiff`].
24//!
25//! JPEG-in-TIFF encode is intentionally out of scope for this round.
26//! CIELab output (3-sample `(L*, a*, b*)` chunky and 1-sample `L*`-only,
27//! `PhotometricInterpretation = 8` per TIFF 6.0 §23) is available via
28//! [`EncodePixelFormat::CieLab8`] and [`EncodePixelFormat::CieLabL8`].
29//! CMYK output (4-sample chunky `(C, M, Y, K)`,
30//! `PhotometricInterpretation = 5` per TIFF 6.0 §16) is available via
31//! [`EncodePixelFormat::Cmyk32`]. YCbCr output (3-sample chunky
32//! `(Y, Cb, Cr)` at 4:4:4 chroma sampling, `PhotometricInterpretation = 6`
33//! per TIFF 6.0 §21 "YCbCr Images") is available via
34//! [`EncodePixelFormat::YCbCr24`]. The horizontal-differencing
35//! predictor (`Predictor = 2`, TIFF 6.0 §14) is supported on encode via
36//! the [`EncodePage::predictor`] flag, and `PlanarConfiguration = 2`
37//! (separate component planes, §"PlanarConfiguration") via
38//! [`EncodePage::planar`].
39
40use crate::ccitt::{encode_ccitt, CcittVariant, FillOrder};
41use crate::compress::{pack_deflate, pack_lzw, pack_packbits, pack_zstd};
42use crate::error::{Result, TiffError as Error};
43use crate::types::*;
44
45/// One palette entry as stored in the on-disk `ColorMap` tag (each
46/// component is a 16-bit value, top-byte is the 8-bit colour).
47pub type RgbColor = [u8; 3];
48
49/// Description of one image page being written.
50///
51/// `pixels` is row-major, packed (no padding between rows). For
52/// 16-bit grayscale pages the bytes are interpreted little-endian
53/// regardless of the stored byte order — the encoder writes II
54/// files and the input bytes are consumed verbatim.
55#[derive(Debug, Clone)]
56pub struct EncodePage<'a> {
57    pub width: u32,
58    pub height: u32,
59    pub kind: EncodePixelFormat<'a>,
60    pub compression: TiffCompression,
61    /// Apply the TIFF 6.0 §14 horizontal-differencing predictor
62    /// (`Predictor = 2`) to the sample data before compression. The
63    /// encoder replaces each component with the difference from the
64    /// previous pixel of the same component (offset `SamplesPerPixel`,
65    /// per §14's "subtract red from red, green from green") and writes
66    /// the `Predictor` tag (317) so the decoder reverses the step. Only
67    /// meaningful for the lossless byte-aligned photometrics whose
68    /// decode path supports it — `Gray8` (8-bit), `Gray16Le` (16-bit),
69    /// `Rgb24` (8-bit × 3), and `Palette8` (8-bit indices). §14 ties
70    /// the predictor to the byte-stream lossless coders (LZW / Deflate,
71    /// and the Compression=50000 Zstandard extension per its trace doc
72    /// §4); combining it with the bilevel CCITT schemes
73    /// (`Compression = 2 / 3`) or with `Bilevel` input is rejected
74    /// with a precise error.
75    pub predictor: bool,
76    /// Write the image in `PlanarConfiguration = 2` (separate component
77    /// planes) layout per TIFF 6.0 §"PlanarConfiguration" (page 38).
78    /// When set, each sample component is stored in its own
79    /// full-resolution strip (one strip per plane), and `StripOffsets`
80    /// / `StripByteCounts` carry `SamplesPerPixel` entries ordered
81    /// component-0, component-1, … — the spec's "SamplesPerPixel rows
82    /// and StripsPerImage columns" array with StripsPerImage = 1. Only
83    /// meaningful for multi-sample formats (`Rgb24`); §"PlanarConfiguration"
84    /// notes the field "is irrelevant" when SamplesPerPixel is 1, so
85    /// `planar` combined with a single-sample format (`Gray8` /
86    /// `Gray16Le` / `Palette8` / `Bilevel`) is rejected with a precise
87    /// error. The §14 predictor still applies when both flags are set:
88    /// §14 says "If PlanarConfiguration is 2 … Differencing works the
89    /// same as it does for grayscale data," so each plane is differenced
90    /// independently with an offset of 1 sample.
91    pub planar: bool,
92    /// Write the image in tiled layout (TIFF 6.0 §15) instead of a
93    /// single strip. `Some((tile_width, tile_height))` divides the image
94    /// into a grid of fixed-size tiles, each compressed independently,
95    /// and writes the `TileWidth` / `TileLength` / `TileOffsets` /
96    /// `TileByteCounts` fields (tags 322 / 323 / 324 / 325) in place of
97    /// the strip fields (§15: "When the tiling fields ... are used, they
98    /// replace the StripOffsets, StripByteCounts, and RowsPerStrip
99    /// fields ... Do not use both strip-oriented and tile-oriented
100    /// fields in the same TIFF file"). Both dimensions must be a multiple
101    /// of 16 per §15's `TileWidth` / `TileLength` requirement. Boundary
102    /// tiles are padded out to the tile geometry (§15 "Padding":
103    /// replicating the last column / row so the padded areas compress
104    /// well); the decoder displays only the `ImageWidth x ImageLength`
105    /// region and ignores the padding. Tiles are laid out left-to-right
106    /// then top-to-bottom (§15 `TileOffsets`). Supported on the
107    /// byte-aligned chunky formats (`Gray8` / `Gray16Le` / `Rgb24` /
108    /// `Palette8`) under None / PackBits / LZW / Deflate / Zstd, with or
109    /// without the §14 predictor (applied per-tile, matching the
110    /// decoder). Tiling
111    /// is rejected on `Bilevel` (sub-byte tile slicing is not implemented
112    /// on either side) and on CCITT compression. It composes with
113    /// `planar = true` on `Rgb24`: one row-major tile grid per component
114    /// plane, emitted plane-0 first then plane-1, etc., per §15
115    /// TileOffsets ("For PlanarConfiguration = 2, the offsets for the
116    /// first component plane are stored first, followed by all the offsets
117    /// for the second component plane").
118    pub tiling: Option<(u32, u32)>,
119    /// Emit BigTIFF instead of classic TIFF. Classic TIFF is the default
120    /// (`false`); when set, the encoder writes the 16-byte BigTIFF header
121    /// (II/MM + magic 43 + offset-bytesize 8 + reserved 0 + 8-byte
122    /// first-IFD offset) per the Adobe Pagemaker 6.0 BigTIFF design that
123    /// the decoder's [`crate::ifd::parse_header`] / [`crate::ifd::parse_ifd`]
124    /// already read. Each IFD then uses 20-byte entries (tag:u16 +
125    /// type:u16 + count:u64 + value-or-offset:u64) with an 8-byte
126    /// next-IFD pointer, the inline-value threshold widens from 4 to 8
127    /// bytes, and the LONG offset/byte-count fields (`StripOffsets`,
128    /// `StripByteCounts`, `TileOffsets`, `TileByteCounts`) are written
129    /// as LONG8 (type 16) so the on-disk layout is no longer pinned to
130    /// the 32-bit u32 ceiling that classic TIFF enforces (the encoder
131    /// returns precise `Error::Unsupported` if the final byte address
132    /// exceeds `u32::MAX` on a classic page; BigTIFF lifts that limit
133    /// to the full u64 file-offset range). The pixel / IFD-entry
134    /// semantics are otherwise identical to classic TIFF — all the
135    /// pixel formats, compressors, predictor / planar / tiling flags
136    /// compose with `bigtiff = true` unchanged.
137    ///
138    /// For [`encode_tiff_multi`], every page must agree on the variant
139    /// (all classic or all BigTIFF); mixing is rejected with a precise
140    /// error.
141    pub bigtiff: bool,
142}
143
144/// Pixel layouts the encoder knows how to write.
145#[derive(Debug, Clone)]
146pub enum EncodePixelFormat<'a> {
147    /// 1-bit bilevel (1 sample per pixel, MSB-first byte packing).
148    /// `pixels` must contain `ceil(width / 8) * height` bytes. The
149    /// bit convention follows §10 / §11: bit value 0 = white,
150    /// 1 = black. The `WhiteIsZero` (default) PhotometricInterpretation
151    /// reads those bits straight through; combine with
152    /// `BlackIsZero` by inverting the input first. Required for
153    /// CCITT compression schemes ([`TiffCompression::CcittRle`],
154    /// [`TiffCompression::CcittT4OneD`]).
155    Bilevel { pixels: &'a [u8] },
156    /// 1-bit transparency mask (TIFF 6.0 §"PhotometricInterpretation"
157    /// value 4, page 37). 1 sample per pixel, MSB-first byte packing —
158    /// the on-disk layout is identical to [`Self::Bilevel`], but the
159    /// encoder writes `PhotometricInterpretation = 4` (Transparency
160    /// Mask) and sets bit 2 of `NewSubfileType` (tag 254), which the
161    /// spec defines as "1 if the image defines a transparency mask
162    /// for another image in this TIFF file. The PhotometricInterpretation
163    /// value must be 4". `pixels` must contain `ceil(width / 8) * height`
164    /// bytes; the bit convention is fixed by spec (§Photometric-
165    /// Interpretation page 37: "The 1-bits define the interior of the
166    /// region; the 0-bits define the exterior of the region"). The
167    /// spec recommends PackBits but does not forbid the other
168    /// compressions; this encoder accepts None / PackBits / LZW /
169    /// Deflate / Zstd / CCITT-MH / CCITT-T.4-1D, the same compressor
170    /// set [`Self::Bilevel`] accepts.
171    TransparencyMask { pixels: &'a [u8] },
172    /// 8-bit greyscale (BlackIsZero, 1 sample per pixel).
173    /// `pixels.len() == width * height`.
174    Gray8 { pixels: &'a [u8] },
175    /// 16-bit greyscale (BlackIsZero, 1 sample per pixel,
176    /// little-endian on disk). `pixels.len() == width * height * 2`.
177    Gray16Le { pixels: &'a [u8] },
178    /// 8-bit packed RGB. `pixels.len() == width * height * 3`.
179    Rgb24 { pixels: &'a [u8] },
180    /// 8-bit indexed palette. `indices.len() == width * height`,
181    /// `palette.len() <= 256` (any extras are ignored).
182    Palette8 {
183        indices: &'a [u8],
184        palette: &'a [RgbColor],
185    },
186    /// 8-bit chunky 1976 CIE L\*a\*b\* (`PhotometricInterpretation = 8`,
187    /// `SamplesPerPixel = 3`, `BitsPerSample = 8 / 8 / 8`) per TIFF 6.0
188    /// §23 "CIE L\*a\*b\* Images" (page 110). `pixels` is row-major
189    /// interleaved `(L*, a*, b*)` triples — `pixels.len() == width *
190    /// height * 3`. The on-disk bit interpretation is fixed by §23: L\*
191    /// is unsigned 0..255 mapping linearly to the perceptual 0..100
192    /// lightness scale, and a\* / b\* are two's-complement signed bytes
193    /// in -128..127 representing the red/green and yellow/blue chrominance
194    /// channels (§23: "The a\* and b\* ranges will be represented as
195    /// signed 8 bit values"). The encoder writes these bytes through to
196    /// the strip / tile / plane payload verbatim — the caller owns the
197    /// colourimetric encoding, exactly as the decoder takes them
198    /// verbatim back off disk. Compressors accepted: None / PackBits /
199    /// LZW / Deflate / Zstd (the byte-aligned, photometric-agnostic set
200    /// the other multi-bit photometric paths use); CCITT is bilevel-only
201    /// per §10 / §11 and rejected here. `Predictor = 2` (TIFF 6.0 §14
202    /// horizontal differencing, per-component on chunky multi-sample
203    /// data) composes; `PlanarConfiguration = 2` (separate L\* / a\* /
204    /// b\* component planes) composes (§14 says differencing in planar
205    /// "works the same as it does for grayscale data" — each plane is
206    /// differenced independently with an offset of one sample); tiled
207    /// layout (§15) composes for both chunky and planar.
208    CieLab8 { pixels: &'a [u8] },
209    /// 8-bit 1-sample CIE L\* monochrome (`PhotometricInterpretation =
210    /// 8`, `SamplesPerPixel = 1`, `BitsPerSample = 8`) per TIFF 6.0 §23
211    /// page 110 "Usage of other Fields": "SamplesPerPixel - ExtraSamples:
212    /// 3 for L\*a\*b\*, 1 implies L\* only, for monochrome data".
213    /// `pixels.len() == width * height`. Each byte is L\* on the
214    /// 0..255-maps-to-0..100 scale. As with [`Self::CieLab8`], the bytes
215    /// are written through verbatim. Compressors accepted: None /
216    /// PackBits / LZW / Deflate / Zstd. `Predictor = 2` composes (single-sample
217    /// chunky path with offset = 1); `PlanarConfiguration = 2` is
218    /// rejected per §"PlanarConfiguration" "irrelevant" for
219    /// `SamplesPerPixel = 1`; tiled layout composes.
220    CieLabL8 { pixels: &'a [u8] },
221    /// 8-bit chunky CMYK per TIFF 6.0 §16 "CMYK Images" (page 69):
222    /// `PhotometricInterpretation = 5`, `SamplesPerPixel = 4`,
223    /// `BitsPerSample = 8, 8, 8, 8`. `pixels` is row-major interleaved
224    /// cyan, magenta, yellow, black quadruples — its length must equal
225    /// `width * height * 4`. The on-disk bit interpretation is fixed
226    /// by §16: each component is the amount of that ink at the pixel
227    /// on the canonical `InkSet = 1` CMYK ordering, where 0 means no
228    /// ink and 255 means full coverage (§16 `InkSet` page 70: "Usually,
229    /// a value of 0 represents 0 % ink coverage and a value of 255
230    /// represents 100 % ink coverage for that component"). The encoder
231    /// writes the caller-supplied bytes through to the strip, tile, or
232    /// plane payload verbatim — the caller owns the colourimetric
233    /// encoding, exactly as the decoder takes them verbatim off disk
234    /// and collapses them into additive RGB by the §16 "amount of dye"
235    /// convention. Alongside the §16-required Baseline tags
236    /// `SamplesPerPixel`, `BitsPerSample`, and `PhotometricInterpretation`,
237    /// the encoder also writes the two optional §16 separated-image
238    /// tags `InkSet = 1` (tag 332, the "CMYK" InkSet value) and
239    /// `NumberOfInks = 4` (tag 334). Both match their §16 defaults,
240    /// but emitting them explicitly makes the written file
241    /// self-describing to readers that key on those fields. Compressors
242    /// accepted: the byte-aligned, photometric-agnostic set the other
243    /// multi-bit photometric paths use (None, PackBits, LZW, Deflate,
244    /// Zstd).
245    /// CCITT is bilevel-only per §10 and §11 and rejected here.
246    /// `Predictor = 2` (TIFF 6.0 §14 horizontal differencing,
247    /// per-component on chunky multi-sample data with offset
248    /// `SamplesPerPixel = 4`) composes. `PlanarConfiguration = 2`
249    /// composes too: each of the four component planes is written as
250    /// its own strip per §"PlanarConfiguration", and the §14 predictor
251    /// differences each plane independently with an offset of one
252    /// sample (§14: "Differencing works the same as it does for
253    /// grayscale data" when PlanarConfiguration is 2). Tiled layout
254    /// (§15) composes for both chunky and planar.
255    Cmyk32 { pixels: &'a [u8] },
256    /// 8-bit chunky `(Y, Cb, Cr)` per TIFF 6.0 §21 "YCbCr Images"
257    /// (page 89), `PhotometricInterpretation = 6`, `SamplesPerPixel = 3`,
258    /// `BitsPerSample = 8, 8, 8` at the §21 chroma-subsampling-1:1
259    /// (`YCbCrSubSampling = [1, 1]`) layout. `pixels` is row-major,
260    /// interleaved `(Y, Cb, Cr)` triples — `pixels.len() == width *
261    /// height * 3`. At `YCbCrSubSampling = [1, 1]` the §21 "Ordering
262    /// of Component Samples" rule for `PlanarConfiguration = 1`
263    /// collapses to one Y sample per data unit (1×1 luminance grid)
264    /// followed by one Cb sample and one Cr sample — i.e. the bytes
265    /// are written through to the strip / tile payload exactly as the
266    /// caller supplied them, with no re-tiling. The caller owns the
267    /// RGB→YCbCr conversion; the encoder transports the supplied bytes
268    /// verbatim, matching the decoder's `build_rgb24_from_ycbcr` path
269    /// which treats §21's stated chunky data-unit layout as fact.
270    /// Alongside the §21 / Baseline tags (`SamplesPerPixel`,
271    /// `BitsPerSample`, `PhotometricInterpretation`), the encoder
272    /// emits the three §21 fields with their full-range
273    /// no-headroom/no-footroom values fixed by §20
274    /// "ReferenceBlackWhite" page 87 ("Useful ReferenceBlackWhite
275    /// values for YCbCr images are: `[0, 255, 128, 255, 128, 255]`
276    /// no headroom/footroom"):
277    ///
278    /// * `YCbCrSubSampling = [1, 1]` (tag 530), the chunky-444 layout
279    ///   the encoder writes;
280    /// * `YCbCrPositioning = 1` (tag 531), §21 "centered" — the
281    ///   tag's documented default and the only positioning that
282    ///   matters when both subsampling factors are 1;
283    /// * `ReferenceBlackWhite = [0/1, 255/1, 128/1, 255/1, 128/1,
284    ///   255/1]` (tag 532), the §20 page 87 "no headroom/footroom"
285    ///   reference coding range — §21 says this field "must be used
286    ///   explicitly" for Class Y images.
287    ///
288    /// The §21 default `YCbCrCoefficients` (tag 529, CCIR
289    /// Recommendation 601-1's `{299/1000, 587/1000, 114/1000}`
290    /// luma weights) are omitted: §21 explicitly defaults to those
291    /// values and the decoder's `ycbcr_to_rgb` matrix is the Q16
292    /// inverse of the same `{0.299, 0.587, 0.114}` coefficients, so
293    /// emitting the tag would just restate the spec default. Future
294    /// rounds add chroma-subsampled (`YCbCrSubSampling = [2, 1]`,
295    /// `[2, 2]`, etc.) and `PlanarConfiguration = 2` writers; the
296    /// 1:1 chunky surface here is the minimal §21-conformant slice.
297    /// Compressors accepted: None / PackBits / LZW / Deflate / Zstd
298    /// (the byte-aligned photometric-agnostic set the other multi-bit
299    /// photometric paths use). CCITT is bilevel-only per §10 / §11
300    /// and rejected here. `Predictor = 2`,
301    /// `PlanarConfiguration = 2`, and tiled layout are deferred —
302    /// the §21 sample-ordering rule changes shape under non-1:1
303    /// subsampling, so the encoder pins those flags off in this
304    /// initial YCbCr round and rejects the combinations with a
305    /// precise error rather than emitting something the decoder
306    /// might mis-tile.
307    YCbCr24 { pixels: &'a [u8] },
308    /// 8-bit chroma-subsampled `(Y, Cb, Cr)` per TIFF 6.0 §21 "YCbCr
309    /// Images" (pages 90–94), `PhotometricInterpretation = 6`,
310    /// `SamplesPerPixel = 3`, `BitsPerSample = [8, 8, 8]`,
311    /// `PlanarConfiguration = 1` (chunky). `pixels` is the
312    /// **full-resolution** row-major interleaved `(Y, Cb, Cr)` raster
313    /// (`pixels.len() == width * height * 3`); the encoder performs the
314    /// chroma decimation and the §21 "Ordering of Component Samples"
315    /// data-unit packing.
316    ///
317    /// `subsampling` is `(ChromaSubsampleHoriz, ChromaSubsampleVert)`
318    /// from the §21 `YCbCrSubSampling` field (tag 530). §21 page 90
319    /// restricts each factor to `1`, `2`, or `4` and requires
320    /// `YCbCrSubsampleVert <= YCbCrSubsampleHoriz`, so the supported
321    /// set is `(1,1)`, `(2,1)`, `(2,2)`, `(4,1)`, `(4,2)` — the same
322    /// configurations the decoder's full-resolution chroma-splat path
323    /// reverses. `(1,1)` is accepted here too (it is the trivial
324    /// no-subsampling case) and behaves identically to
325    /// [`EncodePixelFormat::YCbCr24`].
326    ///
327    /// §21 page 90: "ImageWidth and ImageLength are constrained to be
328    /// integer multiples of YCbCrSubsampleHoriz and
329    /// YCbCrSubsampleVert respectively." The encoder rejects a
330    /// `width`/`height` that is not a multiple of the corresponding
331    /// factor with a precise error.
332    ///
333    /// Data-unit layout (§21 page 93): a data unit is
334    /// `ChromaSubsampleVert` rows of `ChromaSubsampleHoriz` Y samples
335    /// (row-major), then one Cb sample, then one Cr sample. For
336    /// `(sh, sv) = (4, 2)` the worked example on page 94 is
337    /// `Y00, Y01, Y02, Y03, Y10, Y11, Y12, Y13, Cb00, Cr00, Y04, …`.
338    /// The encoder visits data units left-to-right then top-to-bottom
339    /// and emits exactly this byte order, which is what the decoder's
340    /// `build_rgb24_from_ycbcr` block walker consumes.
341    ///
342    /// Chroma decimation: each `sh × sv` block's Cb / Cr is the rounded
343    /// arithmetic mean of the block's full-resolution Cb / Cr samples
344    /// (a box filter — the symmetric even-tap filter §21 page 92 calls
345    /// out for `YCbCrPositioning = 1` centered subsampling). The Y
346    /// samples are transported full-resolution and unchanged, so the
347    /// luminance round-trips bit-exact; chroma round-trips exactly only
348    /// when it is already constant across each block (decode splats one
349    /// Cb / Cr back over the whole block).
350    ///
351    /// The same §21 / §20 fields the 1:1 path emits are written, with
352    /// tag 530 carrying the actual `[sh, sv]`. `YCbCrPositioning = 1`
353    /// (centered) is emitted to match the box-filter decimation.
354    /// Compressors accepted: None / PackBits / LZW / Deflate / Zstd.
355    /// CCITT is bilevel-only and rejected. `Predictor = 2`,
356    /// `PlanarConfiguration = 2`, and tiled layout are rejected with a
357    /// precise error (the data-unit packing is single-strip chunky
358    /// only in this round).
359    YCbCrSubsampled24 {
360        pixels: &'a [u8],
361        subsampling: (u16, u16),
362    },
363}
364
365/// Compression scheme for an [`EncodePage`].
366#[derive(Debug, Clone, Copy)]
367pub enum TiffCompression {
368    None,
369    PackBits,
370    Lzw,
371    Deflate,
372    /// Compression=50000 — Zstandard (RFC 8478), the de-facto
373    /// registry extension documented in the OxideAV trace doc
374    /// `docs/image/tiff/tiff-zstd-compression-50000.md`. Follows the
375    /// Compression=8 Deflate template exactly: each strip / tile
376    /// becomes one self-contained Zstandard frame over the
377    /// post-predictor sample bytes, so it composes with every
378    /// byte-aligned pixel format, `Predictor = 2`, planar writing,
379    /// tiling, and BigTIFF just like [`TiffCompression::Deflate`].
380    /// The encoder compression level is an out-of-band runtime
381    /// parameter (never stored in the file); the writer uses the
382    /// compression backend's default.
383    Zstd,
384    /// Compression=2 — CCITT Modified Huffman (TIFF 6.0 §10). Bilevel
385    /// only. Encoded as a sequence of white/black run-length codes
386    /// from Tables 1/T.4 and 2/T.4. No EOL codes; rows align to byte
387    /// boundaries.
388    CcittRle,
389    /// Compression=3 — CCITT T.4 1-D (TIFF 6.0 §11). Bilevel only.
390    /// Each row is preceded by a 12-bit EOL prefix. With
391    /// `eol_byte_aligned`, the EOL is byte-aligned (T4Options bit 2).
392    CcittT4OneD {
393        eol_byte_aligned: bool,
394    },
395    /// Compression=3 — CCITT T.4 2-D / Modified READ (TIFF 6.0 §11
396    /// with T4Options bit 0 set). Bilevel only. Row 0 is coded 1-D
397    /// (tag bit 1) and seeds the reference line for row 1; rows
398    /// 1.. are coded 2-D (tag bit 0) against the previously coded
399    /// row using the Pass / Horizontal / Vertical mode codes from
400    /// Table 4/T.4 (docs §1). `eol_byte_aligned` mirrors T4Options
401    /// bit 2 just as in [`TiffCompression::CcittT4OneD`].
402    CcittT4TwoD {
403        eol_byte_aligned: bool,
404    },
405    /// Compression=4 — CCITT T.6 / Modified Modified READ (MMR)
406    /// (TIFF 6.0 §11). Bilevel only. Every row is 2-D against the
407    /// previously coded row; the first row's reference is an
408    /// imaginary all-white line per T.6 §2.2.1. No EOL framing
409    /// between rows. The decoder stops at `rows` rows so no EOFB
410    /// sentinel is written.
411    CcittT6,
412}
413
414impl TiffCompression {
415    fn tag_value(self) -> u16 {
416        match self {
417            TiffCompression::None => COMPRESSION_NONE,
418            TiffCompression::PackBits => COMPRESSION_PACKBITS,
419            TiffCompression::Lzw => COMPRESSION_LZW,
420            TiffCompression::Deflate => COMPRESSION_DEFLATE_ADOBE,
421            TiffCompression::Zstd => COMPRESSION_ZSTD,
422            TiffCompression::CcittRle => COMPRESSION_CCITT_HUFFMAN,
423            // Compression=3 covers both T.4 1-D and T.4 2-D; the
424            // T4Options tag (292) distinguishes them on the wire.
425            TiffCompression::CcittT4OneD { .. } | TiffCompression::CcittT4TwoD { .. } => {
426                COMPRESSION_CCITT_T4
427            }
428            TiffCompression::CcittT6 => COMPRESSION_CCITT_T6,
429        }
430    }
431
432    /// Compress `raw` per this scheme. For bilevel CCITT schemes the
433    /// caller supplies the geometry; non-CCITT schemes ignore those
434    /// arguments.
435    fn pack(self, raw: &[u8], width: u32, rows: u32) -> Result<Vec<u8>> {
436        match self {
437            TiffCompression::None => Ok(raw.to_vec()),
438            TiffCompression::PackBits => Ok(pack_packbits(raw)),
439            TiffCompression::Lzw => Ok(pack_lzw(raw)),
440            TiffCompression::Deflate => pack_deflate(raw),
441            TiffCompression::Zstd => pack_zstd(raw),
442            TiffCompression::CcittRle => encode_ccitt(
443                raw,
444                width,
445                rows,
446                CcittVariant::ModifiedHuffman,
447                FillOrder::MsbFirst,
448            ),
449            TiffCompression::CcittT4OneD { eol_byte_aligned } => encode_ccitt(
450                raw,
451                width,
452                rows,
453                CcittVariant::T4OneD { eol_byte_aligned },
454                FillOrder::MsbFirst,
455            ),
456            TiffCompression::CcittT4TwoD { eol_byte_aligned } => encode_ccitt(
457                raw,
458                width,
459                rows,
460                CcittVariant::T4TwoD { eol_byte_aligned },
461                FillOrder::MsbFirst,
462            ),
463            TiffCompression::CcittT6 => {
464                encode_ccitt(raw, width, rows, CcittVariant::T6, FillOrder::MsbFirst)
465            }
466        }
467    }
468
469    /// Bilevel CCITT schemes accept only [`EncodePixelFormat::Bilevel`].
470    fn is_ccitt(self) -> bool {
471        matches!(
472            self,
473            TiffCompression::CcittRle
474                | TiffCompression::CcittT4OneD { .. }
475                | TiffCompression::CcittT4TwoD { .. }
476                | TiffCompression::CcittT6
477        )
478    }
479}
480
481/// One planned page's compressed image segments plus its IFD and any
482/// out-of-line value blobs. `strips` is the segment payload list — one
483/// entry for a chunky single-strip page, `SamplesPerPixel` entries for
484/// `PlanarConfiguration = 2`, or one entry per tile (row-major) for a
485/// tiled page (TIFF 6.0 §15). The on-disk offset / byte-count arrays
486/// (`StripOffsets` / `StripByteCounts` or `TileOffsets` /
487/// `TileByteCounts`, depending on which tags the IFD carries) index
488/// into this list in storage order, so the address-assignment pass is
489/// identical for strips and tiles.
490struct PlannedPage {
491    strips: Vec<Vec<u8>>,
492    ifd: PageIfd,
493    externals: Vec<(BlobId, Vec<u8>)>,
494}
495
496/// Encode a single-page TIFF file. Produces the complete byte
497/// sequence. Convenience wrapper around [`encode_tiff_multi`].
498pub fn encode_tiff(page: &EncodePage<'_>) -> Result<Vec<u8>> {
499    encode_tiff_multi(std::slice::from_ref(page))
500}
501
502/// Encode a multi-page TIFF file (one IFD per page, chained via
503/// the next-IFD pointer in file order). Produces the complete byte
504/// sequence.
505pub fn encode_tiff_multi(pages: &[EncodePage<'_>]) -> Result<Vec<u8>> {
506    if pages.is_empty() {
507        return Err(Error::invalid("TIFF encode: must supply at least one page"));
508    }
509
510    // All pages must agree on the on-disk variant — a classic-TIFF file
511    // and a BigTIFF file have incompatible IFD layouts, so we cannot
512    // mix them in one chain.
513    let bigtiff = pages[0].bigtiff;
514    if pages.iter().any(|p| p.bigtiff != bigtiff) {
515        return Err(Error::invalid(
516            "TIFF encode: encode_tiff_multi pages must all agree on `bigtiff` (cannot mix \
517             classic-TIFF and BigTIFF IFDs in one file)",
518        ));
519    }
520
521    // Layout strategy:
522    //
523    // 1. Header. Classic: 8 bytes (II + 42 + 4-byte first-IFD offset).
524    //    BigTIFF: 16 bytes (II + 43 + 2-byte off-size=8 + 2-byte
525    //    reserved=0 + 8-byte first-IFD offset), per Adobe Pagemaker 6.0
526    //    BigTIFF.
527    // 2. For each page in order:
528    //    a. Compressed strip / tile data.
529    //    b. Out-of-line value blobs that don't fit inline (BitsPerSample
530    //       array for RGB, ColorMap for palette, StripOffsets /
531    //       StripByteCounts arrays for >1 strip/tile).
532    //    c. The IFD itself (count + N × entry + next-IFD; size depends
533    //       on the variant).
534    //
535    // We compute layout in two passes: first sizing pass, then write
536    // pass. The IFD offset of page N+1 is the next-IFD field of
537    // page N's IFD.
538
539    // Variant-dependent constants. All addresses go through these so the
540    // classic / BigTIFF write paths share one set of loops.
541    let header_size: u64 = if bigtiff { 16 } else { 8 };
542    let entry_size: u64 = if bigtiff { 20 } else { 12 }; // u16+u16+count+value-or-offset
543    let count_size: u64 = if bigtiff { 8 } else { 2 }; // IFD entry-count field
544    let next_ifd_size: u64 = if bigtiff { 8 } else { 4 }; // next-IFD slot
545    let inline_threshold: usize = if bigtiff { 8 } else { 4 };
546    let offset_bytes: usize = if bigtiff { 8 } else { 4 }; // value-or-offset slot width
547    let array_align: u64 = if bigtiff { 8 } else { 4 }; // LONG8 vs LONG alignment
548
549    // ---- Sizing pass: per-page, derive the on-disk layout ----
550    let mut planned: Vec<PlannedPage> = Vec::with_capacity(pages.len());
551    for p in pages {
552        let plan = plan_page_full(p, bigtiff)?;
553        planned.push(plan);
554    }
555
556    // ---- Address assignment ----
557    //
558    // Start at byte `header_size` (after the variant-specific header).
559    // For each page we lay out:
560    // [compressed strip(s) / tile(s)][external blobs][StripOffsets /
561    // ByteCounts LONG (classic) or LONG8 (BigTIFF) arrays, only when
562    // count > 1][IFD].
563    //
564    // We need to know the IFD offset *before* writing it (it goes in
565    // the previous IFD's next-IFD slot or in the header for the
566    // first page), so we walk pages once to assign byte ranges. The
567    // StripOffsets / StripByteCounts arrays are written out-of-line
568    // only when there are multiple strips/tiles; a single-strip chunky
569    // page keeps both inline in the IFD value slot.
570    let mut cursor: u64 = header_size;
571    struct PlannedPageAddr {
572        // One (offset, size) per strip / tile, in storage order.
573        strips: Vec<(u64, u64)>,
574        externals: Vec<(BlobId, u64, u64)>, // (id, offset, size)
575        // File offsets of the out-of-line StripOffsets / StripByteCounts
576        // arrays (only Some when there is more than one strip/tile).
577        strip_offsets_array: Option<u64>,
578        strip_byte_counts_array: Option<u64>,
579        ifd_offset: u64,
580    }
581    let mut addrs: Vec<PlannedPageAddr> = Vec::with_capacity(planned.len());
582    for plan in &planned {
583        // Strip / tile payloads, laid out contiguously in storage order.
584        let mut strip_addrs = Vec::with_capacity(plan.strips.len());
585        for strip in &plan.strips {
586            strip_addrs.push((cursor, strip.len() as u64));
587            cursor += strip.len() as u64;
588        }
589        let mut ext_addrs = Vec::with_capacity(plan.externals.len());
590        for (id, blob) in &plan.externals {
591            // SHORT arrays are 2-byte aligned; RATIONAL arrays are
592            // 4-byte aligned (each value is a 4-byte LONG numerator
593            // followed by a 4-byte LONG denominator, so a 4-byte
594            // alignment is the natural one). The 4-byte choice is
595            // a safe superset for the SHORT blobs too.
596            let align: u64 = match id {
597                BlobId::ReferenceBlackWhite => 4,
598                BlobId::BitsPerSample | BlobId::ColorMapWords => 2,
599            };
600            if cursor % align != 0 {
601                cursor += align - (cursor % align);
602            }
603            ext_addrs.push((*id, cursor, blob.len() as u64));
604            cursor += blob.len() as u64;
605        }
606        // Out-of-line StripOffsets / StripByteCounts arrays for
607        // multi-strip / multi-tile pages. Classic TIFF uses LONG
608        // (4 bytes per value); BigTIFF uses LONG8 (8 bytes per value),
609        // so the alignment + per-entry stride both follow the variant.
610        let (strip_offsets_array, strip_byte_counts_array) = if plan.strips.len() > 1 {
611            if cursor % array_align != 0 {
612                cursor += array_align - (cursor % array_align);
613            }
614            let so = cursor;
615            cursor += array_align * plan.strips.len() as u64;
616            let sbc = cursor;
617            cursor += array_align * plan.strips.len() as u64;
618            (Some(so), Some(sbc))
619        } else {
620            (None, None)
621        };
622        // IFD must be 2-byte aligned (entries start with u16 fields).
623        if cursor % 2 != 0 {
624            cursor += 1;
625        }
626        let ifd_offset = cursor;
627        // count(2 or 8) + entries × (12 or 20) + next_ifd(4 or 8)
628        let ifd_size = count_size + (plan.ifd.entries.len() as u64) * entry_size + next_ifd_size;
629        cursor += ifd_size;
630        addrs.push(PlannedPageAddr {
631            strips: strip_addrs,
632            externals: ext_addrs,
633            strip_offsets_array,
634            strip_byte_counts_array,
635            ifd_offset,
636        });
637        // Classic TIFF caps every offset (and therefore the total file
638        // size if the IFD is at the end of the file) at u32::MAX.
639        // BigTIFF lifts the cap to the full u64 range — there is no
640        // documented BigTIFF size ceiling in the Adobe Pagemaker 6.0
641        // design beyond what u64 can express.
642        if !bigtiff && (ifd_offset > u32::MAX as u64 || cursor > u32::MAX as u64) {
643            return Err(Error::invalid(
644                "TIFF encode: classic-TIFF 32-bit offset overflow (would need BigTIFF — set \
645                 EncodePage::bigtiff = true)",
646            ));
647        }
648    }
649
650    // ---- Write pass ----
651    let total = cursor as usize;
652    let mut out = vec![0u8; total];
653    // Header — classic 8-byte or BigTIFF 16-byte.
654    if bigtiff {
655        // II + 43 + offset-bytesize 8 + reserved 0 + 8-byte first-IFD
656        // offset. Per the BigTIFF design (`docs/image/tiff/tiff6.pdf`
657        // BigTIFF / Adobe Pagemaker 6.0 sections, reproduced in
658        // `src/ifd.rs`).
659        out[0] = b'I';
660        out[1] = b'I';
661        out[2..4].copy_from_slice(&BIGTIFF_MAGIC.to_le_bytes());
662        out[4..6].copy_from_slice(&8u16.to_le_bytes()); // offset bytesize
663        out[6..8].copy_from_slice(&0u16.to_le_bytes()); // reserved
664        out[8..16].copy_from_slice(&addrs[0].ifd_offset.to_le_bytes());
665    } else {
666        out[0] = b'I';
667        out[1] = b'I';
668        out[2..4].copy_from_slice(&TIFF_MAGIC.to_le_bytes());
669        out[4..8].copy_from_slice(&(addrs[0].ifd_offset as u32).to_le_bytes());
670    }
671
672    for (i, (plan, addr)) in planned.iter().zip(addrs.iter()).enumerate() {
673        // Strip / tile payload(s).
674        for (strip, (off, size)) in plan.strips.iter().zip(addr.strips.iter()) {
675            assert_eq!(strip.len() as u64, *size);
676            out[*off as usize..(*off + *size) as usize].copy_from_slice(strip);
677        }
678        // External blobs.
679        for (j, (id, off, size)) in addr.externals.iter().enumerate() {
680            assert_eq!(*id, plan.externals[j].0);
681            let blob = &plan.externals[j].1;
682            assert_eq!(blob.len() as u64, *size);
683            out[*off as usize..(*off + *size) as usize].copy_from_slice(blob);
684        }
685        // Out-of-line StripOffsets / StripByteCounts arrays (only present
686        // for multi-strip / multi-tile pages). Classic TIFF writes
687        // 4 bytes per entry (LONG); BigTIFF writes 8 bytes (LONG8).
688        if let Some(so) = addr.strip_offsets_array {
689            for (k, (off, _)) in addr.strips.iter().enumerate() {
690                let slot = so as usize + k * (array_align as usize);
691                if bigtiff {
692                    out[slot..slot + 8].copy_from_slice(&off.to_le_bytes());
693                } else {
694                    out[slot..slot + 4].copy_from_slice(&(*off as u32).to_le_bytes());
695                }
696            }
697        }
698        if let Some(sbc) = addr.strip_byte_counts_array {
699            for (k, (_, size)) in addr.strips.iter().enumerate() {
700                let slot = sbc as usize + k * (array_align as usize);
701                if bigtiff {
702                    out[slot..slot + 8].copy_from_slice(&size.to_le_bytes());
703                } else {
704                    out[slot..slot + 4].copy_from_slice(&(*size as u32).to_le_bytes());
705                }
706            }
707        }
708        // IFD.
709        let ifd_off = addr.ifd_offset as usize;
710        // Entry-count field — 2 bytes in classic TIFF, 8 bytes in BigTIFF.
711        if bigtiff {
712            out[ifd_off..ifd_off + 8]
713                .copy_from_slice(&(plan.ifd.entries.len() as u64).to_le_bytes());
714        } else {
715            out[ifd_off..ifd_off + 2]
716                .copy_from_slice(&(plan.ifd.entries.len() as u16).to_le_bytes());
717        }
718        let entries_start = ifd_off + count_size as usize;
719        let next_ifd_off = entries_start + plan.ifd.entries.len() * (entry_size as usize);
720        // Resolve each entry's value-or-offset slot. The entry layout
721        // differs between variants:
722        //   classic: tag(2) + type(2) + count(4)  + value/offset(4)   = 12 bytes
723        //   BigTIFF: tag(2) + type(2) + count(8)  + value/offset(8)   = 20 bytes
724        for (k, e) in plan.ifd.entries.iter().enumerate() {
725            let entry_off = entries_start + k * (entry_size as usize);
726            out[entry_off..entry_off + 2].copy_from_slice(&e.tag.to_le_bytes());
727            out[entry_off + 2..entry_off + 4].copy_from_slice(&e.field_type.to_le_bytes());
728            if bigtiff {
729                out[entry_off + 4..entry_off + 12].copy_from_slice(&e.count.to_le_bytes());
730            } else {
731                // Classic TIFF stores count as u32; if a caller-side count
732                // somehow exceeds u32::MAX in classic mode the address
733                // assignment above would have already errored, but we
734                // guard defensively.
735                if e.count > u32::MAX as u64 {
736                    return Err(Error::invalid(
737                        "TIFF encode: classic-TIFF entry count exceeds u32::MAX",
738                    ));
739                }
740                out[entry_off + 4..entry_off + 8].copy_from_slice(&(e.count as u32).to_le_bytes());
741            }
742            // Value-or-offset slot:
743            let slot_off = entry_off + if bigtiff { 12 } else { 8 };
744            let slot = &mut out[slot_off..slot_off + offset_bytes];
745            match &e.value {
746                IfdValue::Inline(bytes) => {
747                    let n = bytes.len();
748                    debug_assert!(n <= inline_threshold);
749                    slot[..n].copy_from_slice(bytes);
750                    for b in &mut slot[n..] {
751                        *b = 0;
752                    }
753                }
754                IfdValue::StripOffsets => {
755                    let val: u64 = if let Some(so) = addr.strip_offsets_array {
756                        // >1 strip / tile: value slot holds the file
757                        // offset of the out-of-line LONG / LONG8 array.
758                        so
759                    } else {
760                        // Single strip / tile: the offset fits inline
761                        // (LONG for classic — 4 bytes; LONG8 for BigTIFF
762                        // — 8 bytes, exactly filling the value slot).
763                        addr.strips[0].0
764                    };
765                    if bigtiff {
766                        slot.copy_from_slice(&val.to_le_bytes());
767                    } else {
768                        slot.copy_from_slice(&(val as u32).to_le_bytes());
769                    }
770                }
771                IfdValue::StripByteCounts => {
772                    let val: u64 = if let Some(sbc) = addr.strip_byte_counts_array {
773                        sbc
774                    } else {
775                        addr.strips[0].1
776                    };
777                    if bigtiff {
778                        slot.copy_from_slice(&val.to_le_bytes());
779                    } else {
780                        slot.copy_from_slice(&(val as u32).to_le_bytes());
781                    }
782                }
783                IfdValue::ExternalBlob(id) => {
784                    let (_, off, _) = addr
785                        .externals
786                        .iter()
787                        .find(|(x, _, _)| *x == *id)
788                        .ok_or_else(|| {
789                            Error::invalid("TIFF encode: missing planned blob for entry")
790                        })?;
791                    if bigtiff {
792                        slot.copy_from_slice(&off.to_le_bytes());
793                    } else {
794                        slot.copy_from_slice(&(*off as u32).to_le_bytes());
795                    }
796                }
797            }
798        }
799        // Next-IFD pointer.
800        let next_offset: u64 = if i + 1 < addrs.len() {
801            addrs[i + 1].ifd_offset
802        } else {
803            0
804        };
805        if bigtiff {
806            out[next_ifd_off..next_ifd_off + 8].copy_from_slice(&next_offset.to_le_bytes());
807        } else {
808            out[next_ifd_off..next_ifd_off + 4]
809                .copy_from_slice(&(next_offset as u32).to_le_bytes());
810        }
811    }
812    Ok(out)
813}
814
815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
816enum BlobId {
817    BitsPerSample,
818    ColorMapWords,
819    /// 6-RATIONAL `ReferenceBlackWhite` (tag 532) blob — six
820    /// 8-byte RATIONAL values = 48 bytes, always out of line.
821    /// Carries the §20 "no headroom/footroom" full-range YCbCr
822    /// reference coding ([0/1, 255/1, 128/1, 255/1, 128/1, 255/1]).
823    ReferenceBlackWhite,
824}
825
826#[derive(Debug, Clone)]
827enum IfdValue {
828    /// Up to 4 bytes packed inline into the value/offset slot.
829    Inline(Vec<u8>),
830    /// Resolved at write time: the page's `StripOffsets` array. For a
831    /// single strip (chunky) the LONG fits inline; for
832    /// `PlanarConfiguration = 2` the per-plane offsets are written as a
833    /// LONG array out-of-line and this slot holds its file offset.
834    StripOffsets,
835    /// Resolved at write time: the page's `StripByteCounts` array,
836    /// inline for a single strip or an out-of-line LONG array for
837    /// multiple strips.
838    StripByteCounts,
839    /// Reference to an external blob attached to this page.
840    ExternalBlob(BlobId),
841}
842
843#[derive(Debug, Clone)]
844struct PageIfdEntry {
845    tag: u16,
846    field_type: u16,
847    /// Field-value count (`count` in IFD parlance). Classic TIFF stores
848    /// this as a u32; BigTIFF as a u64. We keep it u64 here and narrow
849    /// at write time, with a range check for the classic path.
850    count: u64,
851    value: IfdValue,
852}
853
854#[derive(Debug)]
855struct PageIfd {
856    entries: Vec<PageIfdEntry>,
857}
858
859fn plan_page_full(p: &EncodePage<'_>, bigtiff: bool) -> Result<PlannedPage> {
860    // CCITT schemes are bilevel-only per TIFF 6.0 §10 / §11. Both the
861    // generic Bilevel input and the TransparencyMask variant carry 1-bit
862    // data with identical on-disk packing and so satisfy that gate.
863    if p.compression.is_ccitt()
864        && !matches!(
865            p.kind,
866            EncodePixelFormat::Bilevel { .. } | EncodePixelFormat::TransparencyMask { .. }
867        )
868    {
869        return Err(Error::invalid(
870            "TIFF encode: CCITT compression (Compression=2/3) requires Bilevel or \
871             TransparencyMask input",
872        ));
873    }
874
875    // PlanarConfiguration=2 (separate component planes) is only
876    // meaningful when there is more than one sample per pixel; TIFF 6.0
877    // §"PlanarConfiguration" (page 38): "If SamplesPerPixel is 1,
878    // PlanarConfiguration is irrelevant." Reject the single-sample
879    // formats and the bit-packed bilevel format up front. The
880    // multi-sample formats the encoder writes are Rgb24 (SPP=3),
881    // CieLab8 (SPP=3 — three 8-bit L*/a*/b* component planes per
882    // §"PlanarConfiguration"), and Cmyk32 (SPP=4 — four 8-bit
883    // C / M / Y / K component planes per §16 + §"PlanarConfiguration").
884    if p.planar
885        && !matches!(
886            p.kind,
887            EncodePixelFormat::Rgb24 { .. }
888                | EncodePixelFormat::CieLab8 { .. }
889                | EncodePixelFormat::Cmyk32 { .. }
890        )
891    {
892        return Err(Error::invalid(
893            "TIFF encode: PlanarConfiguration=2 (separate planes) requires a multi-sample \
894             format; TIFF 6.0 §\"PlanarConfiguration\" says the field is irrelevant when \
895             SamplesPerPixel is 1 (Gray8 / Gray16Le / Palette8 / Bilevel / TransparencyMask / \
896             CieLabL8)",
897        ));
898    }
899    // CCITT schemes are bilevel-only (rejected above) so they never
900    // reach the planar path; the predictor is handled per-plane below.
901
902    // YCbCr24 encode is restricted to the chunky-444 surface in this
903    // round: `PlanarConfiguration = 2` and tiled layout both depend on
904    // the §21 "Ordering of Component Samples" data-unit shape, which
905    // collapses to the trivial `(Y, Cb, Cr)` interleave only at
906    // `YCbCrSubSampling = [1, 1]`. Future rounds add the
907    // chroma-subsampled data-unit packer plus the planar / tiled
908    // §21 layouts; until then, reject the combinations precisely so
909    // the writer never emits something the decoder might mis-tile.
910    if matches!(
911        p.kind,
912        EncodePixelFormat::YCbCr24 { .. } | EncodePixelFormat::YCbCrSubsampled24 { .. }
913    ) {
914        if p.planar {
915            return Err(Error::invalid(
916                "TIFF encode: PlanarConfiguration=2 with YCbCr is not supported in this round \
917                 (the §21 data-unit ordering changes shape under non-1:1 subsampling)",
918            ));
919        }
920        if p.tiling.is_some() {
921            return Err(Error::invalid(
922                "TIFF encode: tiled layout with YCbCr is not supported in this round \
923                 (the §21 data-unit packing is single-strip chunky only here)",
924            ));
925        }
926        if p.predictor {
927            return Err(Error::invalid(
928                "TIFF encode: Predictor=2 with YCbCr is not supported in this round \
929                 (the §21 chroma-difference samples and the §14 cumulative-add reversal \
930                 interact through the §22 / §20 reference coding range)",
931            ));
932        }
933    }
934
935    // Tiled layout (TIFF 6.0 §15). Validate the geometry up front.
936    if let Some((tw, th)) = p.tiling {
937        // §15 TileWidth / TileLength: "TileWidth must be a multiple of
938        // 16 … TileLength must be a multiple of 16 for compatibility
939        // with compression schemes such as JPEG."
940        if tw == 0 || th == 0 || tw % 16 != 0 || th % 16 != 0 {
941            return Err(Error::invalid(format!(
942                "TIFF encode: tile dimensions must be non-zero multiples of 16 \
943                 (TIFF 6.0 §15 TileWidth / TileLength); got {tw}x{th}"
944            )));
945        }
946        // Bilevel tiles need sub-byte tile-row slicing the decoder
947        // rejects, and the CCITT coders are bilevel-only, so tiling is
948        // restricted to the byte-aligned chunky formats. The
949        // TransparencyMask variant carries identical 1-bit packing and
950        // is rejected for the same reason.
951        if matches!(
952            p.kind,
953            EncodePixelFormat::Bilevel { .. } | EncodePixelFormat::TransparencyMask { .. }
954        ) {
955            return Err(Error::invalid(
956                "TIFF encode: tiled layout (TIFF 6.0 §15) is not supported for 1-bit input \
957                 (Bilevel / TransparencyMask) — sub-byte tile slicing is unimplemented on \
958                 both sides",
959            ));
960        }
961        if p.compression.is_ccitt() {
962            return Err(Error::invalid(
963                "TIFF encode: tiled layout cannot combine with CCITT compression \
964                 (Compression=2/3), which is bilevel-only",
965            ));
966        }
967        // Planar tile write (one tile grid per component plane, TIFF 6.0
968        // §15 TileOffsets: "For PlanarConfiguration = 2, the offsets for
969        // the first component plane are stored first, followed by all the
970        // offsets for the second component plane, and so on") is handled
971        // by `build_tiles_planar` below. It is only meaningful for the
972        // multi-sample formats, which `p.planar` already restricts to
973        // Rgb24 (SPP=3) / CieLab8 (SPP=3) / Cmyk32 (SPP=4) — the
974        // single-sample formats are rejected above.
975    }
976
977    // The §14 horizontal-differencing predictor operates on whole
978    // sample components; it has no meaning for bit-packed bilevel data,
979    // and §14 only defines it for the LZW-family lossless coders. Reject
980    // the impossible combinations up front so they surface precisely
981    // rather than producing a file the decoder can't reverse.
982    if p.predictor {
983        if matches!(
984            p.kind,
985            EncodePixelFormat::Bilevel { .. } | EncodePixelFormat::TransparencyMask { .. }
986        ) {
987            return Err(Error::invalid(
988                "TIFF encode: Predictor=2 (horizontal differencing) is undefined for 1-bit \
989                 input (Bilevel / TransparencyMask) — TIFF 6.0 §14 differences whole sample \
990                 components",
991            ));
992        }
993        if p.compression.is_ccitt() {
994            return Err(Error::invalid(
995                "TIFF encode: Predictor=2 cannot combine with CCITT compression \
996                 (Compression=2/3); TIFF 6.0 §14 ties the predictor to the LZW family",
997            ));
998        }
999    }
1000
1001    let (samples_per_pixel, bits_per_sample, photometric, mut raw_pixels, color_map_words) =
1002        match &p.kind {
1003            EncodePixelFormat::Bilevel { pixels } => {
1004                let row_bytes = (p.width as usize).div_ceil(8);
1005                let want = row_bytes * (p.height as usize);
1006                if pixels.len() != want {
1007                    return Err(Error::invalid(format!(
1008                        "TIFF encode/Bilevel: pixel buffer is {} bytes, expected {want} \
1009                         (row_bytes={row_bytes}, height={})",
1010                        pixels.len(),
1011                        p.height
1012                    )));
1013                }
1014                // §10 / §11: "The 'normal' PhotometricInterpretation
1015                // for bilevel CCITT compressed data is WhiteIsZero".
1016                // We follow the same default for uncompressed bilevel
1017                // so a Bilevel input round-trips through any
1018                // compression scheme without changing meaning. The
1019                // decoder applies the photometric inversion on the
1020                // way to Gray8.
1021                (1u16, vec![1u16], PHOTO_WHITE_IS_ZERO, pixels.to_vec(), None)
1022            }
1023            EncodePixelFormat::TransparencyMask { pixels } => {
1024                // TIFF 6.0 page 37 "PhotometricInterpretation = 4":
1025                // SamplesPerPixel and BitsPerSample must be 1; bytes
1026                // are packed MSB-first row-by-row exactly like Bilevel.
1027                // The bit polarity is fixed by spec — 1 = interior,
1028                // 0 = exterior — and the encoder does not apply any
1029                // inversion, so the input is written through verbatim.
1030                // The NewSubfileType bit-2 flag is set further down so
1031                // multi-page readers can recognise the IFD as a mask
1032                // for a sibling image without consulting the photometric
1033                // tag.
1034                let row_bytes = (p.width as usize).div_ceil(8);
1035                let want = row_bytes * (p.height as usize);
1036                if pixels.len() != want {
1037                    return Err(Error::invalid(format!(
1038                        "TIFF encode/TransparencyMask: pixel buffer is {} bytes, expected \
1039                         {want} (row_bytes={row_bytes}, height={})",
1040                        pixels.len(),
1041                        p.height
1042                    )));
1043                }
1044                (
1045                    1u16,
1046                    vec![1u16],
1047                    PHOTO_TRANSPARENCY_MASK,
1048                    pixels.to_vec(),
1049                    None,
1050                )
1051            }
1052            EncodePixelFormat::Gray8 { pixels } => {
1053                let want = (p.width as usize) * (p.height as usize);
1054                if pixels.len() != want {
1055                    return Err(Error::invalid(format!(
1056                        "TIFF encode/Gray8: pixel buffer is {} bytes, expected {want}",
1057                        pixels.len()
1058                    )));
1059                }
1060                (1u16, vec![8u16], PHOTO_BLACK_IS_ZERO, pixels.to_vec(), None)
1061            }
1062            EncodePixelFormat::Gray16Le { pixels } => {
1063                let want = (p.width as usize) * (p.height as usize) * 2;
1064                if pixels.len() != want {
1065                    return Err(Error::invalid(format!(
1066                        "TIFF encode/Gray16Le: pixel buffer is {} bytes, expected {want}",
1067                        pixels.len()
1068                    )));
1069                }
1070                (
1071                    1u16,
1072                    vec![16u16],
1073                    PHOTO_BLACK_IS_ZERO,
1074                    pixels.to_vec(),
1075                    None,
1076                )
1077            }
1078            EncodePixelFormat::Rgb24 { pixels } => {
1079                let want = (p.width as usize) * (p.height as usize) * 3;
1080                if pixels.len() != want {
1081                    return Err(Error::invalid(format!(
1082                        "TIFF encode/Rgb24: pixel buffer is {} bytes, expected {want}",
1083                        pixels.len()
1084                    )));
1085                }
1086                (3u16, vec![8u16, 8, 8], PHOTO_RGB, pixels.to_vec(), None)
1087            }
1088            EncodePixelFormat::Palette8 { indices, palette } => {
1089                let want = (p.width as usize) * (p.height as usize);
1090                if indices.len() != want {
1091                    return Err(Error::invalid(format!(
1092                        "TIFF encode/Palette8: index buffer is {} bytes, expected {want}",
1093                        indices.len()
1094                    )));
1095                }
1096                if palette.is_empty() || palette.len() > 256 {
1097                    return Err(Error::invalid(format!(
1098                        "TIFF encode/Palette8: palette must have 1..=256 entries (got {})",
1099                        palette.len()
1100                    )));
1101                }
1102                // ColorMap stores 256 SHORTs per channel for an 8-bpp
1103                // palette per spec; pad missing entries with 0.
1104                let mut cm = vec![0u16; 256 * 3];
1105                for (i, c) in palette.iter().enumerate() {
1106                    // Replicate 8-bit channel into the high byte of
1107                    // the 16-bit ColorMap entry (upper bits =
1108                    // intensity). The canonical (v << 8) | v
1109                    // expansion ensures a 0xFF 8-bit value reads
1110                    // back as 0xFFFF in the 16-bit field.
1111                    cm[i] = ((c[0] as u16) << 8) | c[0] as u16;
1112                    cm[256 + i] = ((c[1] as u16) << 8) | c[1] as u16;
1113                    cm[512 + i] = ((c[2] as u16) << 8) | c[2] as u16;
1114                }
1115                (1u16, vec![8u16], PHOTO_PALETTE, indices.to_vec(), Some(cm))
1116            }
1117            EncodePixelFormat::CieLab8 { pixels } => {
1118                // TIFF 6.0 §23 "CIE L*a*b* Images" (page 110):
1119                // 3-sample chunky `(L*, a*, b*)` at 8 bits per sample.
1120                // The on-disk bit interpretation is fixed by the spec
1121                // — L* is unsigned 0..255 mapping to the 0..100
1122                // perceptual lightness scale, and a*, b* are
1123                // two's-complement signed bytes — so the encoder
1124                // writes the caller-supplied bytes through verbatim.
1125                // BitsPerSample is a 3-entry [8, 8, 8] SHORT array,
1126                // identical to Rgb24, so the inline-vs-out-of-line
1127                // spill machinery already in `plan_page_full` handles
1128                // it for both classic (spill out-of-line, 6 > 4) and
1129                // BigTIFF (stay inline, 6 <= 8).
1130                let want = (p.width as usize) * (p.height as usize) * 3;
1131                if pixels.len() != want {
1132                    return Err(Error::invalid(format!(
1133                        "TIFF encode/CieLab8: pixel buffer is {} bytes, expected {want}",
1134                        pixels.len()
1135                    )));
1136                }
1137                (3u16, vec![8u16, 8, 8], PHOTO_CIELAB, pixels.to_vec(), None)
1138            }
1139            EncodePixelFormat::CieLabL8 { pixels } => {
1140                // TIFF 6.0 §23 page 110 "Usage of other Fields":
1141                // "SamplesPerPixel - ExtraSamples: 3 for L*a*b*, 1
1142                // implies L* only, for monochrome data". One byte per
1143                // pixel, BitsPerSample = [8]. Bytes are L* on the
1144                // 0..255 -> 0..100 perceptual lightness scale.
1145                let want = (p.width as usize) * (p.height as usize);
1146                if pixels.len() != want {
1147                    return Err(Error::invalid(format!(
1148                        "TIFF encode/CieLabL8: pixel buffer is {} bytes, expected {want}",
1149                        pixels.len()
1150                    )));
1151                }
1152                (1u16, vec![8u16], PHOTO_CIELAB, pixels.to_vec(), None)
1153            }
1154            EncodePixelFormat::Cmyk32 { pixels } => {
1155                // TIFF 6.0 §16 "CMYK Images" (page 69) — 4-sample chunky
1156                // (C, M, Y, K) at 8 bits per sample. The on-disk bit
1157                // interpretation is fixed by the spec (§16 Requirements:
1158                // "SamplesPerPixel = N. SHORT. The number of inks. … For
1159                // CMYK, … N = 4 … BitsPerSample = 8,8,8,8 … the
1160                // larger component values represent a higher percentage
1161                // of ink dot coverage and smaller values represent less
1162                // coverage"), so the encoder writes the caller-supplied
1163                // bytes through verbatim. BitsPerSample is a 4-entry
1164                // [8, 8, 8, 8] SHORT array (8 bytes) — it spills
1165                // out-of-line on classic TIFF (8 > 4 inline threshold)
1166                // and stays inline on BigTIFF (8 <= 8), exactly as the
1167                // existing `bps_inline_bytes <= inline_threshold` switch
1168                // already handles.
1169                let want = (p.width as usize) * (p.height as usize) * 4;
1170                if pixels.len() != want {
1171                    return Err(Error::invalid(format!(
1172                        "TIFF encode/Cmyk32: pixel buffer is {} bytes, expected {want}",
1173                        pixels.len()
1174                    )));
1175                }
1176                (4u16, vec![8u16, 8, 8, 8], PHOTO_CMYK, pixels.to_vec(), None)
1177            }
1178            EncodePixelFormat::YCbCr24 { pixels } => {
1179                // TIFF 6.0 §21 "YCbCr Images" (page 89) — 3-sample
1180                // chunky `(Y, Cb, Cr)` at 8 bits per sample under the
1181                // chunky-444 layout (`YCbCrSubSampling = [1, 1]`,
1182                // `PlanarConfiguration = 1`). At the 1:1 chroma
1183                // sampling factor the §21 "Ordering of Component
1184                // Samples" data-unit (ChromaSubsampleVert rows of
1185                // ChromaSubsampleHoriz Y samples, then one Cb and one
1186                // Cr) collapses to a single Y followed by Cb and Cr
1187                // per pixel — i.e. the caller-supplied bytes are the
1188                // on-disk byte order. BitsPerSample is a 3-entry
1189                // [8, 8, 8] SHORT array (6 bytes) — it spills
1190                // out-of-line on classic TIFF (6 > 4) and stays inline
1191                // on BigTIFF (6 <= 8), same as the Rgb24 / CieLab8
1192                // paths. The §21-required `YCbCrSubSampling = [1, 1]`
1193                // (tag 530), `YCbCrPositioning = 1` (tag 531,
1194                // "centered"), and `ReferenceBlackWhite = [0/1, 255/1,
1195                // 128/1, 255/1, 128/1, 255/1]` (tag 532, the §20
1196                // page 87 "no headroom/footroom" full-range value)
1197                // are emitted further down in the IFD-build pass.
1198                let want = (p.width as usize) * (p.height as usize) * 3;
1199                if pixels.len() != want {
1200                    return Err(Error::invalid(format!(
1201                        "TIFF encode/YCbCr24: pixel buffer is {} bytes, expected {want}",
1202                        pixels.len()
1203                    )));
1204                }
1205                (3u16, vec![8u16, 8, 8], PHOTO_YCBCR, pixels.to_vec(), None)
1206            }
1207            EncodePixelFormat::YCbCrSubsampled24 {
1208                pixels,
1209                subsampling,
1210            } => {
1211                let (sh, sv) = *subsampling;
1212                // §21 page 90: each subsampling factor is 1, 2, or 4 and
1213                // YCbCrSubsampleVert <= YCbCrSubsampleHoriz. The decoder
1214                // reverses exactly this legal set.
1215                if !matches!((sh, sv), (1, 1) | (2, 1) | (2, 2) | (4, 1) | (4, 2)) {
1216                    return Err(Error::invalid(format!(
1217                        "TIFF encode/YCbCrSubsampled24: YCbCrSubSampling=({sh},{sv}) is not a \
1218                         TIFF 6.0 §21 legal pair — each factor must be 1/2/4 and \
1219                         YCbCrSubsampleVert <= YCbCrSubsampleHoriz; supported pairs are \
1220                         (1,1), (2,1), (2,2), (4,1), (4,2)"
1221                    )));
1222                }
1223                // §21 page 90: "ImageWidth and ImageLength are
1224                // constrained to be integer multiples of
1225                // YCbCrSubsampleHoriz and YCbCrSubsampleVert
1226                // respectively."
1227                if p.width % (sh as u32) != 0 || p.height % (sv as u32) != 0 {
1228                    return Err(Error::invalid(format!(
1229                        "TIFF encode/YCbCrSubsampled24: ImageWidth ({}) must be a multiple of \
1230                         ChromaSubsampleHoriz ({sh}) and ImageLength ({}) a multiple of \
1231                         ChromaSubsampleVert ({sv}) — TIFF 6.0 §21 page 90",
1232                        p.width, p.height
1233                    )));
1234                }
1235                let want = (p.width as usize) * (p.height as usize) * 3;
1236                if pixels.len() != want {
1237                    return Err(Error::invalid(format!(
1238                        "TIFF encode/YCbCrSubsampled24: pixel buffer is {} bytes, expected {want} \
1239                         (full-resolution width * height * 3)",
1240                        pixels.len()
1241                    )));
1242                }
1243                let packed = pack_ycbcr_data_units(
1244                    pixels,
1245                    p.width as usize,
1246                    p.height as usize,
1247                    sh as usize,
1248                    sv as usize,
1249                );
1250                (3u16, vec![8u16, 8, 8], PHOTO_YCBCR, packed, None)
1251            }
1252        };
1253
1254    // Build the page's compressed image segments. Chunky pages are a
1255    // single strip over the interleaved data; PlanarConfiguration=2
1256    // pages emit one strip per component plane (full image height), in
1257    // plane order (component 0 first), matching the decoder's planar
1258    // walker; tiled pages emit one segment per tile (row-major, TIFF 6.0
1259    // §15), each independently compressed.
1260    let bps = bits_per_sample[0] as usize;
1261    let strips: Vec<Vec<u8>> = if let Some((tile_w, tile_h)) = p.tiling {
1262        if p.planar {
1263            // Tiled PlanarConfiguration=2 (TIFF 6.0 §15 + §"Planar-
1264            // Configuration"): one row-major tile grid per component
1265            // plane, emitted plane-0 first then plane-1, etc. — exactly
1266            // the order §15 TileOffsets prescribes ("the offsets for the
1267            // first component plane are stored first, followed by all the
1268            // offsets for the second component plane, and so on"). Only
1269            // Rgb24 (SPP=3) reaches here.
1270            build_tiles_planar(
1271                &raw_pixels,
1272                p.width as usize,
1273                p.height as usize,
1274                tile_w as usize,
1275                tile_h as usize,
1276                samples_per_pixel as usize,
1277                bps,
1278                p.predictor,
1279                p.compression,
1280            )?
1281        } else {
1282            // Tiled chunky layout (TIFF 6.0 §15). Split the interleaved
1283            // chunky raster into a row-major grid of tile_w x tile_h
1284            // tiles, padding boundary tiles by replicating the last
1285            // visible column / row (§15 "Padding": "Some compression
1286            // schemes work best if the padding is accomplished by
1287            // replicating the last column and last row"). Each tile is
1288            // differenced (when the predictor is on) and compressed
1289            // independently — §15: "Tiles are compressed individually,
1290            // just as strips are compressed."
1291            build_tiles(
1292                &raw_pixels,
1293                p.width as usize,
1294                p.height as usize,
1295                tile_w as usize,
1296                tile_h as usize,
1297                samples_per_pixel as usize,
1298                bps,
1299                p.predictor,
1300                p.compression,
1301            )?
1302        }
1303    } else if p.planar {
1304        // De-interleave chunky RGBRGB… into separate R / G / B planes
1305        // (TIFF 6.0 §"PlanarConfiguration": "Red components in one
1306        // component plane, the Green in another, and the Blue in
1307        // another"). Only Rgb24 (SPP=3, 8 bits) reaches here.
1308        let spp = samples_per_pixel as usize;
1309        let bytes_per_sample = bps / 8;
1310        let pixels = (p.width as usize) * (p.height as usize);
1311        let plane_len = pixels * bytes_per_sample;
1312        let mut out_strips = Vec::with_capacity(spp);
1313        for plane in 0..spp {
1314            let mut plane_buf = vec![0u8; plane_len];
1315            for px in 0..pixels {
1316                let src = (px * spp + plane) * bytes_per_sample;
1317                let dst = px * bytes_per_sample;
1318                plane_buf[dst..dst + bytes_per_sample]
1319                    .copy_from_slice(&raw_pixels[src..src + bytes_per_sample]);
1320            }
1321            // §14: "If PlanarConfiguration is 2 … Differencing works the
1322            // same as it does for grayscale data." Each plane is a
1323            // single-component image, so the predictor runs with an
1324            // offset of one sample.
1325            if p.predictor {
1326                let plane_row_bytes = (p.width as usize) * bytes_per_sample;
1327                forward_horizontal_predictor(
1328                    &mut plane_buf,
1329                    p.width as usize,
1330                    p.height as usize,
1331                    1,
1332                    bps,
1333                    plane_row_bytes,
1334                )?;
1335            }
1336            out_strips.push(p.compression.pack(&plane_buf, p.width, p.height)?);
1337        }
1338        out_strips
1339    } else {
1340        // Apply the §14 horizontal-differencing predictor *before*
1341        // compression. The encoder stores first differences; the
1342        // decoder's cumulative left-to-right add reverses it exactly.
1343        // Chunky single-strip layout, so `row_bytes` is the packed
1344        // sample stride and the whole image is one differencing region.
1345        if p.predictor {
1346            let row_bytes = (p.width as usize) * (samples_per_pixel as usize) * (bps / 8);
1347            forward_horizontal_predictor(
1348                &mut raw_pixels,
1349                p.width as usize,
1350                p.height as usize,
1351                samples_per_pixel as usize,
1352                bps,
1353                row_bytes,
1354            )?;
1355        }
1356        vec![p.compression.pack(&raw_pixels, p.width, p.height)?]
1357    };
1358    let planar_config = if p.planar {
1359        PLANAR_SEPARATE
1360    } else {
1361        PLANAR_CHUNKY
1362    };
1363    let strip_count: u64 = strips.len() as u64;
1364
1365    // Build the IFD entry list. Tags must appear in ascending order
1366    // per spec.
1367    let mut entries: Vec<PageIfdEntry> = Vec::new();
1368    let mut externals: Vec<(BlobId, Vec<u8>)> = Vec::new();
1369
1370    // 254 NewSubfileType — TIFF 6.0 page 36, 32-bit bit-flag field.
1371    // Bit 0: reduced-resolution version of another image. Bit 1:
1372    // single page of a multi-page image. Bit 2: defines a
1373    // transparency mask for another image in this TIFF file (the
1374    // spec then requires PhotometricInterpretation = 4). Defaults
1375    // to 0 (full-resolution single image). The encoder sets bit 2
1376    // when the caller asked for a TransparencyMask page so that a
1377    // multi-page reader can spot the mask IFD without consulting
1378    // PhotometricInterpretation. Other bits stay clear; we never
1379    // emit reduced-resolution or generic multi-page-numbering hints.
1380    let new_subfile_type: u32 = if matches!(p.kind, EncodePixelFormat::TransparencyMask { .. }) {
1381        1 << 2
1382    } else {
1383        0
1384    };
1385    entries.push(PageIfdEntry {
1386        tag: TAG_NEW_SUBFILE_TYPE,
1387        field_type: TYPE_LONG,
1388        count: 1,
1389        value: IfdValue::Inline(new_subfile_type.to_le_bytes().to_vec()),
1390    });
1391    // 256 ImageWidth (LONG)
1392    entries.push(PageIfdEntry {
1393        tag: TAG_IMAGE_WIDTH,
1394        field_type: TYPE_LONG,
1395        count: 1,
1396        value: IfdValue::Inline(p.width.to_le_bytes().to_vec()),
1397    });
1398    // 257 ImageLength (LONG)
1399    entries.push(PageIfdEntry {
1400        tag: TAG_IMAGE_LENGTH,
1401        field_type: TYPE_LONG,
1402        count: 1,
1403        value: IfdValue::Inline(p.height.to_le_bytes().to_vec()),
1404    });
1405    // 258 BitsPerSample (SHORT × samples_per_pixel). BigTIFF widens the
1406    // inline value/offset slot from 4 to 8 bytes, so the Rgb24 3-entry
1407    // SHORT array (6 bytes) now fits inline; classic TIFF still has
1408    // to spill it out-of-line.
1409    let bps_inline_bytes: Vec<u8> = bits_per_sample
1410        .iter()
1411        .flat_map(|b| b.to_le_bytes())
1412        .collect();
1413    let inline_threshold: usize = if bigtiff { 8 } else { 4 };
1414    if bps_inline_bytes.len() <= inline_threshold {
1415        entries.push(PageIfdEntry {
1416            tag: TAG_BITS_PER_SAMPLE,
1417            field_type: TYPE_SHORT,
1418            count: bits_per_sample.len() as u64,
1419            value: IfdValue::Inline(bps_inline_bytes),
1420        });
1421    } else {
1422        externals.push((BlobId::BitsPerSample, bps_inline_bytes));
1423        entries.push(PageIfdEntry {
1424            tag: TAG_BITS_PER_SAMPLE,
1425            field_type: TYPE_SHORT,
1426            count: bits_per_sample.len() as u64,
1427            value: IfdValue::ExternalBlob(BlobId::BitsPerSample),
1428        });
1429    }
1430    // 259 Compression (SHORT)
1431    entries.push(PageIfdEntry {
1432        tag: TAG_COMPRESSION,
1433        field_type: TYPE_SHORT,
1434        count: 1,
1435        value: IfdValue::Inline(p.compression.tag_value().to_le_bytes().to_vec()),
1436    });
1437    // 262 PhotometricInterpretation (SHORT)
1438    entries.push(PageIfdEntry {
1439        tag: TAG_PHOTOMETRIC_INTERPRETATION,
1440        field_type: TYPE_SHORT,
1441        count: 1,
1442        value: IfdValue::Inline(photometric.to_le_bytes().to_vec()),
1443    });
1444    // 273 StripOffsets. Omitted entirely for tiled pages — §15: "When
1445    // the tiling fields … are used, they replace the StripOffsets,
1446    // StripByteCounts, and RowsPerStrip fields … Do not use both
1447    // strip-oriented and tile-oriented fields in the same TIFF file."
1448    // count=1 for chunky (one strip), or SamplesPerPixel for
1449    // PlanarConfiguration=2 (one strip per plane). Classic TIFF stores
1450    // offsets as LONG (4 bytes); BigTIFF as LONG8 (8 bytes) so a single
1451    // 4 GiB+ strip can still be addressed inline in the value slot.
1452    let offset_field_type = if bigtiff { TYPE_LONG8 } else { TYPE_LONG };
1453    if p.tiling.is_none() {
1454        entries.push(PageIfdEntry {
1455            tag: TAG_STRIP_OFFSETS,
1456            field_type: offset_field_type,
1457            count: strip_count,
1458            value: IfdValue::StripOffsets,
1459        });
1460    }
1461    // 277 SamplesPerPixel (SHORT)
1462    entries.push(PageIfdEntry {
1463        tag: TAG_SAMPLES_PER_PIXEL,
1464        field_type: TYPE_SHORT,
1465        count: 1,
1466        value: IfdValue::Inline(samples_per_pixel.to_le_bytes().to_vec()),
1467    });
1468    // 278 RowsPerStrip (LONG). Omitted for tiled pages (§15: TileLength
1469    // "Replaces RowsPerStrip in tiled TIFF files").
1470    if p.tiling.is_none() {
1471        entries.push(PageIfdEntry {
1472            tag: TAG_ROWS_PER_STRIP,
1473            field_type: TYPE_LONG,
1474            count: 1,
1475            value: IfdValue::Inline(p.height.to_le_bytes().to_vec()),
1476        });
1477    }
1478    // 279 StripByteCounts. count matches StripOffsets. Omitted for
1479    // tiled pages (replaced by TileByteCounts). LONG/LONG8 picks the
1480    // variant-appropriate offset width (above).
1481    if p.tiling.is_none() {
1482        entries.push(PageIfdEntry {
1483            tag: TAG_STRIP_BYTE_COUNTS,
1484            field_type: offset_field_type,
1485            count: strip_count,
1486            value: IfdValue::StripByteCounts,
1487        });
1488    }
1489    // 284 PlanarConfiguration (SHORT) — 1 (chunky) or 2 (separate
1490    // planes) per the page's `planar` flag.
1491    entries.push(PageIfdEntry {
1492        tag: TAG_PLANAR_CONFIGURATION,
1493        field_type: TYPE_SHORT,
1494        count: 1,
1495        value: IfdValue::Inline(planar_config.to_le_bytes().to_vec()),
1496    });
1497    // 292 T4Options (LONG) — only for Compression=3. Bit 0 (2D
1498    // coding, T4OPT_2D_CODING) is set for the T.4 2-D variant per
1499    // TIFF 6.0 §11; bit 1 (uncompressed mode) is always clear
1500    // (uncompressed mode is unsupported on both sides of the codec);
1501    // bit 2 (EOL byte-aligned) is set per the variant flag.
1502    match p.compression {
1503        TiffCompression::CcittT4OneD { eol_byte_aligned } => {
1504            let mut flags: u32 = 0;
1505            if eol_byte_aligned {
1506                flags |= T4OPT_EOL_BYTE_ALIGNED;
1507            }
1508            entries.push(PageIfdEntry {
1509                tag: TAG_T4_OPTIONS,
1510                field_type: TYPE_LONG,
1511                count: 1,
1512                value: IfdValue::Inline(flags.to_le_bytes().to_vec()),
1513            });
1514        }
1515        TiffCompression::CcittT4TwoD { eol_byte_aligned } => {
1516            let mut flags: u32 = T4OPT_2D_CODING;
1517            if eol_byte_aligned {
1518                flags |= T4OPT_EOL_BYTE_ALIGNED;
1519            }
1520            entries.push(PageIfdEntry {
1521                tag: TAG_T4_OPTIONS,
1522                field_type: TYPE_LONG,
1523                count: 1,
1524                value: IfdValue::Inline(flags.to_le_bytes().to_vec()),
1525            });
1526        }
1527        TiffCompression::CcittT6 => {
1528            // 293 T6Options (LONG). Per §11, bit 0 is reserved and
1529            // bit 1 ("uncompressed mode allowed") is the only
1530            // defined option flag; we never emit T.6 uncompressed
1531            // extensions so the field is all zeros.
1532            entries.push(PageIfdEntry {
1533                tag: TAG_T6_OPTIONS,
1534                field_type: TYPE_LONG,
1535                count: 1,
1536                value: IfdValue::Inline(0u32.to_le_bytes().to_vec()),
1537            });
1538        }
1539        _ => {}
1540    }
1541    // 317 Predictor (SHORT) — only when horizontal differencing is on.
1542    // Default (Predictor=1, no prediction) is omitted; the decoder
1543    // treats an absent tag as 1.
1544    if p.predictor {
1545        entries.push(PageIfdEntry {
1546            tag: TAG_PREDICTOR,
1547            field_type: TYPE_SHORT,
1548            count: 1,
1549            value: IfdValue::Inline(PREDICTOR_HORIZONTAL.to_le_bytes().to_vec()),
1550        });
1551    }
1552    // 320 ColorMap (SHORT, 3*2^bps) — palette only.
1553    if let Some(cm) = color_map_words {
1554        let bytes: Vec<u8> = cm.iter().flat_map(|w| w.to_le_bytes()).collect();
1555        let count = cm.len() as u64;
1556        externals.push((BlobId::ColorMapWords, bytes));
1557        entries.push(PageIfdEntry {
1558            tag: TAG_COLOR_MAP,
1559            field_type: TYPE_SHORT,
1560            count,
1561            value: IfdValue::ExternalBlob(BlobId::ColorMapWords),
1562        });
1563    }
1564    // 322/323/324/325 Tile fields (TIFF 6.0 §15) — only for tiled pages.
1565    // These come after ColorMap (320) in ascending tag order. TileWidth
1566    // / TileLength carry the grid geometry; TileOffsets / TileByteCounts
1567    // index the per-tile payloads in `strips` (row-major), reusing the
1568    // same out-of-line LONG-array machinery the strip arrays use when
1569    // count > 1.
1570    if let Some((tile_w, tile_h)) = p.tiling {
1571        // 322 TileWidth (LONG)
1572        entries.push(PageIfdEntry {
1573            tag: TAG_TILE_WIDTH,
1574            field_type: TYPE_LONG,
1575            count: 1,
1576            value: IfdValue::Inline(tile_w.to_le_bytes().to_vec()),
1577        });
1578        // 323 TileLength (LONG)
1579        entries.push(PageIfdEntry {
1580            tag: TAG_TILE_LENGTH,
1581            field_type: TYPE_LONG,
1582            count: 1,
1583            value: IfdValue::Inline(tile_h.to_le_bytes().to_vec()),
1584        });
1585        // 324 TileOffsets (LONG, or LONG8 in BigTIFF; N = TilesPerImage
1586        // for chunky, SamplesPerPixel × TilesPerImage for planar).
1587        entries.push(PageIfdEntry {
1588            tag: TAG_TILE_OFFSETS,
1589            field_type: offset_field_type,
1590            count: strip_count,
1591            value: IfdValue::StripOffsets,
1592        });
1593        // 325 TileByteCounts (LONG, or LONG8 in BigTIFF).
1594        entries.push(PageIfdEntry {
1595            tag: TAG_TILE_BYTE_COUNTS,
1596            field_type: offset_field_type,
1597            count: strip_count,
1598            value: IfdValue::StripByteCounts,
1599        });
1600    }
1601
1602    // 332 InkSet (SHORT) and 334 NumberOfInks (SHORT) — TIFF 6.0 §16
1603    // pages 70 / 70. Both are optional in §16 (defaults: `InkSet = 1`,
1604    // CMYK; `NumberOfInks = 4`), but the encoder emits them on every
1605    // CMYK page so a reader keying on `InkSet` does not need to fall
1606    // back on the default. We write `InkSet = 1` (canonical CMYK
1607    // ordering: cyan, magenta, yellow, black) and `NumberOfInks = 4`
1608    // to match the four `BitsPerSample` entries. The `InkNames` field
1609    // (tag 333, ASCII per-ink names) only exists when `InkSet = 2`
1610    // ("not CMYK") per §16 InkSet ("The InkNames field should not
1611    // exist when InkSet=1"), so it is never emitted here. Tags 332 +
1612    // 334 sit between 325 (TileByteCounts) and 338 (ExtraSamples, the
1613    // next baseline tag the encoder might later add) in ascending
1614    // IFD-tag order.
1615    if matches!(p.kind, EncodePixelFormat::Cmyk32 { .. }) {
1616        entries.push(PageIfdEntry {
1617            tag: TAG_INK_SET,
1618            field_type: TYPE_SHORT,
1619            count: 1,
1620            value: IfdValue::Inline(INK_SET_CMYK.to_le_bytes().to_vec()),
1621        });
1622        entries.push(PageIfdEntry {
1623            tag: TAG_NUMBER_OF_INKS,
1624            field_type: TYPE_SHORT,
1625            count: 1,
1626            value: IfdValue::Inline(4u16.to_le_bytes().to_vec()),
1627        });
1628    }
1629
1630    // 530 YCbCrSubSampling (SHORT × 2), 531 YCbCrPositioning (SHORT),
1631    // 532 ReferenceBlackWhite (RATIONAL × 6) — TIFF 6.0 §21 "YCbCr
1632    // Images" pages 91 / 92 + §20 "ReferenceBlackWhite" page 87.
1633    // YCbCrCoefficients (tag 529) is omitted because §21 says its
1634    // default is the CCIR Recommendation 601-1 luma weights
1635    // `{299/1000, 587/1000, 114/1000}` and the decoder's matrix is the
1636    // Q16 inverse of those same weights — writing the tag would just
1637    // restate the spec default. The three tags sit between 334
1638    // (NumberOfInks) and the §20-only TransferRange (342) / 700 (XMP)
1639    // / 33432 (Copyright) tags the encoder might later add.
1640    let ycbcr_subsampling: Option<(u16, u16)> = match &p.kind {
1641        EncodePixelFormat::YCbCr24 { .. } => Some((1, 1)),
1642        EncodePixelFormat::YCbCrSubsampled24 { subsampling, .. } => Some(*subsampling),
1643        _ => None,
1644    };
1645    if let Some((sh, sv)) = ycbcr_subsampling {
1646        // 530 YCbCrSubSampling — `[sh, sv]` (§21 page 90). Two SHORTs
1647        // pack into 4 bytes and so fit inline on classic TIFF.
1648        let mut ss = [0u8; 4];
1649        ss[..2].copy_from_slice(&sh.to_le_bytes());
1650        ss[2..4].copy_from_slice(&sv.to_le_bytes());
1651        entries.push(PageIfdEntry {
1652            tag: TAG_YCBCR_SUBSAMPLING,
1653            field_type: TYPE_SHORT,
1654            count: 2,
1655            value: IfdValue::Inline(ss.to_vec()),
1656        });
1657        // 531 YCbCrPositioning — 1 (centered). §21: "Field value 1
1658        // (centered) must be specified for compatibility with
1659        // industry standards such as PostScript Level 2 and
1660        // QuickTime." At `YCbCrSubSampling = [1, 1]` the positioning
1661        // choice is degenerate (no subsampling means there is no
1662        // Cb/Cr offset to specify), but the §21 default is 1 so
1663        // emit it explicitly for self-describing files.
1664        entries.push(PageIfdEntry {
1665            tag: TAG_YCBCR_POSITIONING,
1666            field_type: TYPE_SHORT,
1667            count: 1,
1668            value: IfdValue::Inline(1u16.to_le_bytes().to_vec()),
1669        });
1670        // 532 ReferenceBlackWhite — six RATIONALs carrying the §20
1671        // page 87 "no headroom/footroom" full-range YCbCr values:
1672        // `[0/1, 255/1, 128/1, 255/1, 128/1, 255/1]`. §21 says
1673        // ReferenceBlackWhite "must be used explicitly" for Class Y
1674        // images, and §20 page 87 lists the no-headroom variant
1675        // first among the "Useful ReferenceBlackWhite values for
1676        // YCbCr images." Six RATIONALs = 48 bytes, always
1677        // out-of-line. Each RATIONAL is two LONGs: numerator,
1678        // denominator.
1679        let rbw_pairs: [(u32, u32); 6] = [(0, 1), (255, 1), (128, 1), (255, 1), (128, 1), (255, 1)];
1680        let mut rbw_bytes: Vec<u8> = Vec::with_capacity(48);
1681        for (num, den) in rbw_pairs {
1682            rbw_bytes.extend_from_slice(&num.to_le_bytes());
1683            rbw_bytes.extend_from_slice(&den.to_le_bytes());
1684        }
1685        externals.push((BlobId::ReferenceBlackWhite, rbw_bytes));
1686        entries.push(PageIfdEntry {
1687            tag: TAG_REFERENCE_BLACK_WHITE,
1688            field_type: TYPE_RATIONAL,
1689            count: 6,
1690            value: IfdValue::ExternalBlob(BlobId::ReferenceBlackWhite),
1691        });
1692    }
1693
1694    // Spec: entries must be in ascending tag order. The pushes
1695    // above are already sorted (254/256/257/258/259/262/273?/277/
1696    // 278?/279?/284/292?/317?/320?/322?/323?/324?/325?/332?/334?/
1697    // 530?/531?/532?), but assert defensively.
1698    debug_assert!(entries.windows(2).all(|w| w[0].tag <= w[1].tag));
1699
1700    Ok(PlannedPage {
1701        strips,
1702        ifd: PageIfd { entries },
1703        externals,
1704    })
1705}
1706
1707/// Decimate the chroma of a full-resolution interleaved `(Y, Cb, Cr)`
1708/// raster and pack it into the TIFF 6.0 §21 `PlanarConfiguration = 1`
1709/// data-unit byte order for the given `(sh, sv)` subsampling factors.
1710///
1711/// `src` is `width * height * 3` bytes of full-resolution interleaved
1712/// `(Y, Cb, Cr)`. `width` is an integer multiple of `sh` and `height`
1713/// of `sv` (the caller validates the §21 page 90 constraint before
1714/// calling). The returned buffer is
1715/// `(width / sh) * (height / sv) * (sh * sv + 2)` bytes — one data unit
1716/// per `sh × sv` luminance block.
1717///
1718/// Each data unit (§21 page 93) is laid out as `sv` rows of `sh`
1719/// full-resolution Y samples (row-major), then one Cb, then one Cr.
1720/// For the §21 page 94 worked `(4, 2)` example this emits
1721/// `Y00 Y01 Y02 Y03 Y10 Y11 Y12 Y13 Cb00 Cr00` for the first block.
1722///
1723/// The Cb / Cr written for a block is the rounded arithmetic mean of
1724/// the block's `sh × sv` full-resolution Cb / Cr samples — the
1725/// symmetric even-tap box filter §21 page 92 associates with the
1726/// centered (`YCbCrPositioning = 1`) sample positioning the encoder
1727/// declares. Luminance is transported unchanged.
1728fn pack_ycbcr_data_units(src: &[u8], width: usize, height: usize, sh: usize, sv: usize) -> Vec<u8> {
1729    let block_w = width / sh;
1730    let block_h = height / sv;
1731    let unit_len = sh * sv + 2;
1732    let mut out = vec![0u8; block_w * block_h * unit_len];
1733    for by in 0..block_h {
1734        for bx in 0..block_w {
1735            let unit_off = (by * block_w + bx) * unit_len;
1736            // sv rows of sh Y samples, row-major (§21 page 93).
1737            let mut cb_sum: u32 = 0;
1738            let mut cr_sum: u32 = 0;
1739            for sy in 0..sv {
1740                for sx in 0..sh {
1741                    let px = bx * sh + sx;
1742                    let py = by * sv + sy;
1743                    let s = (py * width + px) * 3;
1744                    out[unit_off + sy * sh + sx] = src[s];
1745                    cb_sum += src[s + 1] as u32;
1746                    cr_sum += src[s + 2] as u32;
1747                }
1748            }
1749            // Rounded mean of the block's chroma — box filter.
1750            let n = (sh * sv) as u32;
1751            let cb = ((cb_sum + n / 2) / n) as u8;
1752            let cr = ((cr_sum + n / 2) / n) as u8;
1753            out[unit_off + sh * sv] = cb;
1754            out[unit_off + sh * sv + 1] = cr;
1755        }
1756    }
1757    out
1758}
1759
1760/// Split a chunky raster into a row-major grid of `tile_w x tile_h`
1761/// tiles, padding boundary tiles by replicating the last visible
1762/// column / row, then (optionally) difference and compress each tile
1763/// independently. Returns one compressed payload per tile, ordered
1764/// left-to-right then top-to-bottom (TIFF 6.0 §15 `TileOffsets`).
1765///
1766/// §15 "Padding": "Boundary tiles are padded to the tile boundaries …
1767/// It doesn't matter what value is used for padding, because good TIFF
1768/// readers display only the pixels defined by ImageWidth and
1769/// ImageLength and ignore any padded pixels. Some compression schemes
1770/// work best if the padding is accomplished by replicating the last
1771/// column and last row instead of padding with 0's." We replicate the
1772/// edge samples so the compressed boundary tiles stay small. §15:
1773/// "Compression includes any padded areas of the rightmost and bottom
1774/// tiles so that all the tiles in an image are the same size when
1775/// uncompressed" — every tile is exactly `tile_w x tile_h` before
1776/// compression.
1777///
1778/// `pixels` is the interleaved chunky raster (`samples` components per
1779/// pixel, `bps` bits each — only 8 / 16 reach here). The predictor,
1780/// when on, is applied per-tile with an offset of `samples`, matching
1781/// the decoder's per-tile `apply_horizontal_predictor`.
1782#[allow(clippy::too_many_arguments)]
1783fn build_tiles(
1784    pixels: &[u8],
1785    width: usize,
1786    height: usize,
1787    tile_w: usize,
1788    tile_h: usize,
1789    samples: usize,
1790    bps: usize,
1791    predictor: bool,
1792    compression: TiffCompression,
1793) -> Result<Vec<Vec<u8>>> {
1794    let bytes_per_sample = bps / 8;
1795    let pixel_bytes = samples * bytes_per_sample;
1796    let image_row_bytes = width * pixel_bytes;
1797    let tile_row_bytes = tile_w * pixel_bytes;
1798    let tile_size_bytes = tile_row_bytes * tile_h;
1799
1800    let tiles_across = width.div_ceil(tile_w);
1801    let tiles_down = height.div_ceil(tile_h);
1802
1803    let mut out = Vec::with_capacity(tiles_across * tiles_down);
1804    for ty in 0..tiles_down {
1805        for tx in 0..tiles_across {
1806            // Extract this tile, replicating the last visible column /
1807            // row into the padded region (§15 "Padding").
1808            let mut tile = vec![0u8; tile_size_bytes];
1809            for r in 0..tile_h {
1810                // Source image row, clamped to the last visible row.
1811                let src_y = (ty * tile_h + r).min(height - 1);
1812                for c in 0..tile_w {
1813                    // Source image column, clamped to the last visible
1814                    // column.
1815                    let src_x = (tx * tile_w + c).min(width - 1);
1816                    let src_off = src_y * image_row_bytes + src_x * pixel_bytes;
1817                    let dst_off = r * tile_row_bytes + c * pixel_bytes;
1818                    tile[dst_off..dst_off + pixel_bytes]
1819                        .copy_from_slice(&pixels[src_off..src_off + pixel_bytes]);
1820                }
1821            }
1822            // §14 / §15: each tile is a self-contained image for the
1823            // predictor — difference per-tile with the tile's own row
1824            // stride so the decoder's per-tile cumulative add reverses
1825            // it exactly.
1826            if predictor {
1827                forward_horizontal_predictor(
1828                    &mut tile,
1829                    tile_w,
1830                    tile_h,
1831                    samples,
1832                    bps,
1833                    tile_row_bytes,
1834                )?;
1835            }
1836            // §15: "Tiles are compressed individually, just as strips
1837            // are compressed." Pass the tile geometry through (only the
1838            // CCITT coders read it, and tiling rejects CCITT upstream).
1839            out.push(compression.pack(&tile, tile_w as u32, tile_h as u32)?);
1840        }
1841    }
1842    Ok(out)
1843}
1844
1845/// Tiled `PlanarConfiguration = 2` layout (TIFF 6.0 §15 + §"Planar-
1846/// Configuration", page 38). De-interleave the chunky raster into one
1847/// single-component plane per sample, tile each plane on the same
1848/// `tile_w x tile_h` grid as the chunky path, and return the compressed
1849/// tiles in plane order: all of plane 0's tiles (row-major,
1850/// left-to-right then top-to-bottom) first, then all of plane 1's, etc.
1851///
1852/// §15 TileOffsets: "For PlanarConfiguration = 2, the offsets for the
1853/// first component plane are stored first, followed by all the offsets
1854/// for the second component plane, and so on." The per-plane tile grid
1855/// is identical to the chunky grid (`TilesPerImage` tiles each), so the
1856/// returned vector has `SamplesPerPixel * TilesPerImage` entries —
1857/// exactly the `N` §15 specifies for TileOffsets / TileByteCounts under
1858/// PlanarConfiguration = 2.
1859///
1860/// Each plane is a single-component image, so boundary padding (§15
1861/// "Padding": replicate the last visible column / row) and the §14
1862/// horizontal-differencing predictor both run with `samples = 1` —
1863/// §14: "If PlanarConfiguration is 2 … Differencing works the same as
1864/// it does for grayscale data." This matches the decoder's
1865/// `decode_tiles_planar`, which reverses each tile with an offset of one
1866/// sample. Only Rgb24 (SPP=3, 8 bits) reaches here.
1867#[allow(clippy::too_many_arguments)]
1868fn build_tiles_planar(
1869    pixels: &[u8],
1870    width: usize,
1871    height: usize,
1872    tile_w: usize,
1873    tile_h: usize,
1874    samples: usize,
1875    bps: usize,
1876    predictor: bool,
1877    compression: TiffCompression,
1878) -> Result<Vec<Vec<u8>>> {
1879    let bytes_per_sample = bps / 8;
1880    let pixel_bytes = samples * bytes_per_sample;
1881    let plane_len = width * height * bytes_per_sample;
1882    let tiles_across = width.div_ceil(tile_w);
1883    let tiles_down = height.div_ceil(tile_h);
1884
1885    let mut out = Vec::with_capacity(samples * tiles_across * tiles_down);
1886    for plane in 0..samples {
1887        // De-interleave this component into a full-resolution
1888        // single-channel plane (§"PlanarConfiguration": "Red components
1889        // in one component plane, the Green in another, and the Blue in
1890        // another").
1891        let mut plane_buf = vec![0u8; plane_len];
1892        let total_pixels = width * height;
1893        for px in 0..total_pixels {
1894            let src = px * pixel_bytes + plane * bytes_per_sample;
1895            let dst = px * bytes_per_sample;
1896            plane_buf[dst..dst + bytes_per_sample]
1897                .copy_from_slice(&pixels[src..src + bytes_per_sample]);
1898        }
1899        // Tile the single-component plane exactly like the chunky path
1900        // with `samples = 1`, so padding and the per-tile predictor reuse
1901        // the same code. The returned tiles are row-major (§15
1902        // TileOffsets: "ordered left-to-right and top-to-bottom").
1903        let plane_tiles = build_tiles(
1904            &plane_buf,
1905            width,
1906            height,
1907            tile_w,
1908            tile_h,
1909            1,
1910            bps,
1911            predictor,
1912            compression,
1913        )?;
1914        out.extend(plane_tiles);
1915    }
1916    Ok(out)
1917}
1918
1919/// Forward horizontal-differencing predictor (TIFF 6.0 §14): replace
1920/// each component with the difference from the previous pixel of the
1921/// same component, in place. The inverse of the decoder's
1922/// `apply_horizontal_predictor`: that routine adds left-to-right, so
1923/// the encoder subtracts right-to-left to keep each "previous" value
1924/// at its *original* magnitude while the difference is taken. §14:
1925/// "we will do our horizontal differences with an offset of
1926/// SamplesPerPixel ... subtract red from red, green from green, and
1927/// blue from blue." Encoder output is always II (little-endian), so
1928/// 16-bit components are read/written little-endian.
1929///
1930/// `row_bytes` is the packed sample stride (chunky, single-strip). The
1931/// "ignore the overflow bits" wrap-around §14 relies on is exactly
1932/// two's-complement `wrapping_sub`.
1933fn forward_horizontal_predictor(
1934    buf: &mut [u8],
1935    width: usize,
1936    rows: usize,
1937    samples: usize,
1938    bps: usize,
1939    row_bytes: usize,
1940) -> Result<()> {
1941    if width == 0 || rows == 0 {
1942        return Ok(());
1943    }
1944    match bps {
1945        8 => {
1946            for r in 0..rows {
1947                let row = &mut buf[r * row_bytes..r * row_bytes + width * samples];
1948                // Right-to-left so row[x - samples] is still the
1949                // original sample when we difference row[x].
1950                for x in (samples..(width * samples)).rev() {
1951                    row[x] = row[x].wrapping_sub(row[x - samples]);
1952                }
1953            }
1954        }
1955        16 => {
1956            for r in 0..rows {
1957                let row = &mut buf[r * row_bytes..r * row_bytes + width * samples * 2];
1958                let pixels = width * samples;
1959                for x in (samples..pixels).rev() {
1960                    let cur_off = x * 2;
1961                    let prev_off = (x - samples) * 2;
1962                    let cur = u16::from_le_bytes([row[cur_off], row[cur_off + 1]]);
1963                    let prev = u16::from_le_bytes([row[prev_off], row[prev_off + 1]]);
1964                    let new = cur.wrapping_sub(prev);
1965                    let bytes = new.to_le_bytes();
1966                    row[cur_off] = bytes[0];
1967                    row[cur_off + 1] = bytes[1];
1968                }
1969            }
1970        }
1971        _ => {
1972            // The encoder only emits 8- and 16-bit components, so this
1973            // is unreachable from the public API; keep the precise
1974            // error for defensiveness / future bit depths.
1975            return Err(Error::invalid(format!(
1976                "TIFF encode: Predictor=2 at bits_per_sample={bps} unsupported"
1977            )));
1978        }
1979    }
1980    Ok(())
1981}
1982
1983#[cfg(test)]
1984mod tests {
1985    use super::*;
1986    use crate::decode_tiff;
1987    use crate::image::TiffPixelFormat;
1988
1989    fn ramp_gray8(w: u32, h: u32) -> Vec<u8> {
1990        let mut v = Vec::with_capacity((w * h) as usize);
1991        for y in 0..h {
1992            for x in 0..w {
1993                v.push(((x + y) & 0xFF) as u8);
1994            }
1995        }
1996        v
1997    }
1998
1999    fn pattern_rgb(w: u32, h: u32) -> Vec<u8> {
2000        let mut v = Vec::with_capacity((w * h * 3) as usize);
2001        for y in 0..h as u8 {
2002            for x in 0..w as u8 {
2003                v.push(x.wrapping_mul(7));
2004                v.push(y.wrapping_mul(11));
2005                v.push((x ^ y).wrapping_mul(13));
2006            }
2007        }
2008        v
2009    }
2010
2011    #[test]
2012    fn encode_gray8_uncompressed_roundtrip() {
2013        let pixels = ramp_gray8(32, 32);
2014        let page = EncodePage {
2015            width: 32,
2016            height: 32,
2017            kind: EncodePixelFormat::Gray8 { pixels: &pixels },
2018            compression: TiffCompression::None,
2019            predictor: false,
2020            planar: false,
2021            tiling: None,
2022            bigtiff: false,
2023        };
2024        let bytes = encode_tiff(&page).unwrap();
2025        let d = decode_tiff(&bytes).unwrap();
2026        assert_eq!((d.width, d.height), (32, 32));
2027        assert_eq!(d.frame.planes[0].data, pixels);
2028    }
2029
2030    #[test]
2031    fn encode_gray16_packbits_roundtrip() {
2032        let mut pixels = Vec::with_capacity(16 * 16 * 2);
2033        for y in 0u16..16 {
2034            for x in 0u16..16 {
2035                let v = (x.wrapping_mul(257)).wrapping_add(y.wrapping_mul(513));
2036                pixels.extend_from_slice(&v.to_le_bytes());
2037            }
2038        }
2039        let page = EncodePage {
2040            width: 16,
2041            height: 16,
2042            kind: EncodePixelFormat::Gray16Le { pixels: &pixels },
2043            compression: TiffCompression::PackBits,
2044            predictor: false,
2045            planar: false,
2046            tiling: None,
2047            bigtiff: false,
2048        };
2049        let bytes = encode_tiff(&page).unwrap();
2050        let d = decode_tiff(&bytes).unwrap();
2051        assert_eq!((d.width, d.height), (16, 16));
2052        assert_eq!(d.frame.planes[0].data, pixels);
2053    }
2054
2055    #[test]
2056    fn encode_rgb24_lzw_roundtrip() {
2057        let pixels = pattern_rgb(20, 20);
2058        let page = EncodePage {
2059            width: 20,
2060            height: 20,
2061            kind: EncodePixelFormat::Rgb24 { pixels: &pixels },
2062            compression: TiffCompression::Lzw,
2063            predictor: false,
2064            planar: false,
2065            tiling: None,
2066            bigtiff: false,
2067        };
2068        let bytes = encode_tiff(&page).unwrap();
2069        let d = decode_tiff(&bytes).unwrap();
2070        assert_eq!((d.width, d.height), (20, 20));
2071        assert_eq!(d.frame.planes[0].data, pixels);
2072    }
2073
2074    #[test]
2075    fn encode_rgb24_deflate_roundtrip() {
2076        let pixels = pattern_rgb(48, 24);
2077        let page = EncodePage {
2078            width: 48,
2079            height: 24,
2080            kind: EncodePixelFormat::Rgb24 { pixels: &pixels },
2081            compression: TiffCompression::Deflate,
2082            predictor: false,
2083            planar: false,
2084            tiling: None,
2085            bigtiff: false,
2086        };
2087        let bytes = encode_tiff(&page).unwrap();
2088        let d = decode_tiff(&bytes).unwrap();
2089        assert_eq!((d.width, d.height), (48, 24));
2090        assert_eq!(d.frame.planes[0].data, pixels);
2091    }
2092
2093    #[test]
2094    fn encode_palette_roundtrip() {
2095        // 4-color palette: black, red, green, white.
2096        let palette = vec![[0, 0, 0], [255, 0, 0], [0, 255, 0], [255, 255, 255]];
2097        let mut indices = Vec::with_capacity(8 * 8);
2098        for y in 0..8 {
2099            for x in 0..8 {
2100                indices.push(((x ^ y) & 0x3) as u8);
2101            }
2102        }
2103        let page = EncodePage {
2104            width: 8,
2105            height: 8,
2106            kind: EncodePixelFormat::Palette8 {
2107                indices: &indices,
2108                palette: &palette,
2109            },
2110            compression: TiffCompression::None,
2111            predictor: false,
2112            planar: false,
2113            tiling: None,
2114            bigtiff: false,
2115        };
2116        let bytes = encode_tiff(&page).unwrap();
2117        let d = decode_tiff(&bytes).unwrap();
2118        // Decoder expands palette → Rgb24.
2119        let mut want = Vec::with_capacity(8 * 8 * 3);
2120        for &idx in &indices {
2121            let p = palette[idx as usize];
2122            want.extend_from_slice(&p);
2123        }
2124        assert_eq!(d.frame.planes[0].data, want);
2125    }
2126
2127    fn bilevel_checkerboard(w: u32, h: u32) -> Vec<u8> {
2128        // Pack an MSB-first 1-bit bilevel buffer with a 1-pixel
2129        // checkerboard pattern. Used to exercise the run-length coder
2130        // on the worst-case input (every pixel is a run boundary).
2131        let row_bytes = (w as usize).div_ceil(8);
2132        let mut out = vec![0u8; row_bytes * h as usize];
2133        for y in 0..h as usize {
2134            for x in 0..w as usize {
2135                let on = ((x ^ y) & 1) == 1;
2136                if on {
2137                    out[y * row_bytes + x / 8] |= 1 << (7 - (x % 8));
2138                }
2139            }
2140        }
2141        out
2142    }
2143
2144    fn bilevel_stripes(w: u32, h: u32, period: u32) -> Vec<u8> {
2145        // Wider runs to exercise the make-up-code paths.
2146        let row_bytes = (w as usize).div_ceil(8);
2147        let mut out = vec![0u8; row_bytes * h as usize];
2148        for y in 0..h as usize {
2149            for x in 0..w as usize {
2150                let on = ((x as u32) / period) & 1 == 1;
2151                if on {
2152                    out[y * row_bytes + x / 8] |= 1 << (7 - (x % 8));
2153                }
2154            }
2155        }
2156        out
2157    }
2158
2159    /// Inflate a packed MSB-first bilevel buffer to Gray8 the same
2160    /// way the decoder would, with the WhiteIsZero convention the
2161    /// `Bilevel` encoder emits (bit 0 = white = 0xFF in Gray8).
2162    fn bilevel_to_gray8(packed: &[u8], w: u32, h: u32) -> Vec<u8> {
2163        let row_bytes = (w as usize).div_ceil(8);
2164        let mut out = Vec::with_capacity((w * h) as usize);
2165        for y in 0..h as usize {
2166            let row = &packed[y * row_bytes..(y + 1) * row_bytes];
2167            for x in 0..w as usize {
2168                let bit = (row[x / 8] >> (7 - (x % 8))) & 1;
2169                // WhiteIsZero photometric: bit 0 -> white -> 0xFF.
2170                out.push(if bit == 0 { 0xFF } else { 0x00 });
2171            }
2172        }
2173        out
2174    }
2175
2176    #[test]
2177    fn encode_bilevel_uncompressed_roundtrip() {
2178        let packed = bilevel_checkerboard(24, 16);
2179        let page = EncodePage {
2180            width: 24,
2181            height: 16,
2182            kind: EncodePixelFormat::Bilevel { pixels: &packed },
2183            compression: TiffCompression::None,
2184            predictor: false,
2185            planar: false,
2186            tiling: None,
2187            bigtiff: false,
2188        };
2189        let bytes = encode_tiff(&page).unwrap();
2190        let d = decode_tiff(&bytes).unwrap();
2191        assert_eq!((d.width, d.height), (24, 16));
2192        let want = bilevel_to_gray8(&packed, 24, 16);
2193        assert_eq!(d.frame.planes[0].data, want);
2194    }
2195
2196    #[test]
2197    fn encode_bilevel_ccitt_rle_roundtrip_checkerboard() {
2198        let packed = bilevel_checkerboard(16, 8);
2199        let page = EncodePage {
2200            width: 16,
2201            height: 8,
2202            kind: EncodePixelFormat::Bilevel { pixels: &packed },
2203            compression: TiffCompression::CcittRle,
2204            predictor: false,
2205            planar: false,
2206            tiling: None,
2207            bigtiff: false,
2208        };
2209        let bytes = encode_tiff(&page).unwrap();
2210        let d = decode_tiff(&bytes).unwrap();
2211        let want = bilevel_to_gray8(&packed, 16, 8);
2212        assert_eq!(d.frame.planes[0].data, want);
2213    }
2214
2215    #[test]
2216    fn encode_bilevel_ccitt_rle_roundtrip_stripes() {
2217        // Width 128 with a 16-pixel stripe period exercises the
2218        // make-up-code path on every run (each run is exactly 16
2219        // pixels = white-terminating or black-terminating directly).
2220        let packed = bilevel_stripes(128, 4, 16);
2221        let page = EncodePage {
2222            width: 128,
2223            height: 4,
2224            kind: EncodePixelFormat::Bilevel { pixels: &packed },
2225            compression: TiffCompression::CcittRle,
2226            predictor: false,
2227            planar: false,
2228            tiling: None,
2229            bigtiff: false,
2230        };
2231        let bytes = encode_tiff(&page).unwrap();
2232        let d = decode_tiff(&bytes).unwrap();
2233        let want = bilevel_to_gray8(&packed, 128, 4);
2234        assert_eq!(d.frame.planes[0].data, want);
2235    }
2236
2237    #[test]
2238    fn encode_bilevel_ccitt_t4_1d_roundtrip() {
2239        let packed = bilevel_stripes(96, 6, 8);
2240        let page = EncodePage {
2241            width: 96,
2242            height: 6,
2243            kind: EncodePixelFormat::Bilevel { pixels: &packed },
2244            compression: TiffCompression::CcittT4OneD {
2245                eol_byte_aligned: false,
2246            },
2247            predictor: false,
2248            planar: false,
2249            tiling: None,
2250            bigtiff: false,
2251        };
2252        let bytes = encode_tiff(&page).unwrap();
2253        let d = decode_tiff(&bytes).unwrap();
2254        let want = bilevel_to_gray8(&packed, 96, 6);
2255        assert_eq!(d.frame.planes[0].data, want);
2256    }
2257
2258    #[test]
2259    fn encode_bilevel_ccitt_t4_1d_byte_aligned_roundtrip() {
2260        // T4Options bit 2 must end up in the IFD and the decoder
2261        // must read it back to find the byte-aligned EOLs.
2262        let packed = bilevel_stripes(64, 8, 4);
2263        let page = EncodePage {
2264            width: 64,
2265            height: 8,
2266            kind: EncodePixelFormat::Bilevel { pixels: &packed },
2267            compression: TiffCompression::CcittT4OneD {
2268                eol_byte_aligned: true,
2269            },
2270            predictor: false,
2271            planar: false,
2272            tiling: None,
2273            bigtiff: false,
2274        };
2275        let bytes = encode_tiff(&page).unwrap();
2276        let d = decode_tiff(&bytes).unwrap();
2277        let want = bilevel_to_gray8(&packed, 64, 8);
2278        assert_eq!(d.frame.planes[0].data, want);
2279    }
2280
2281    #[test]
2282    fn encode_ccitt_rejects_non_bilevel() {
2283        // Asking for CCITT compression with a Gray8 input must be a
2284        // clean error, not a silent mis-encode.
2285        let pixels = ramp_gray8(8, 8);
2286        let page = EncodePage {
2287            width: 8,
2288            height: 8,
2289            kind: EncodePixelFormat::Gray8 { pixels: &pixels },
2290            compression: TiffCompression::CcittRle,
2291            predictor: false,
2292            planar: false,
2293            tiling: None,
2294            bigtiff: false,
2295        };
2296        let r = encode_tiff(&page);
2297        assert!(r.is_err());
2298    }
2299
2300    #[test]
2301    fn encode_multi_page_chain() {
2302        let p1 = ramp_gray8(8, 8);
2303        let p2 = pattern_rgb(8, 8);
2304        let pages = vec![
2305            EncodePage {
2306                width: 8,
2307                height: 8,
2308                kind: EncodePixelFormat::Gray8 { pixels: &p1 },
2309                compression: TiffCompression::None,
2310                predictor: false,
2311                planar: false,
2312                tiling: None,
2313                bigtiff: false,
2314            },
2315            EncodePage {
2316                width: 8,
2317                height: 8,
2318                kind: EncodePixelFormat::Rgb24 { pixels: &p2 },
2319                compression: TiffCompression::Lzw,
2320                predictor: false,
2321                planar: false,
2322                tiling: None,
2323                bigtiff: false,
2324            },
2325        ];
2326        let bytes = encode_tiff_multi(&pages).unwrap();
2327        let imgs = crate::decoder::decode_tiff_all(&bytes).unwrap();
2328        assert_eq!(imgs.len(), 2);
2329        assert_eq!(imgs[0].width, 8);
2330        assert_eq!(imgs[0].planes[0].data, p1);
2331        assert_eq!(imgs[1].width, 8);
2332        assert_eq!(imgs[1].planes[0].data, p2);
2333    }
2334
2335    // ---- Predictor=2 (horizontal differencing, TIFF 6.0 §14) ----
2336
2337    fn pattern_gray16(w: u32, h: u32) -> Vec<u8> {
2338        let mut v = Vec::with_capacity((w * h * 2) as usize);
2339        for y in 0..h {
2340            for x in 0..w {
2341                // Smoothly-varying so differences are small; the
2342                // predictor's correctness is independent of magnitude
2343                // (two's-complement wrap), but a ramp is the realistic
2344                // case §14 targets.
2345                let s = (x.wrapping_mul(311)).wrapping_add(y.wrapping_mul(101)) as u16;
2346                v.extend_from_slice(&s.to_le_bytes());
2347            }
2348        }
2349        v
2350    }
2351
2352    /// Helper: encode `kind` with Predictor=2 + `comp`, decode, and
2353    /// assert the round-trip is bit-exact.
2354    fn predictor_roundtrip(
2355        width: u32,
2356        height: u32,
2357        kind: EncodePixelFormat<'_>,
2358        comp: TiffCompression,
2359    ) -> Vec<u8> {
2360        let page = EncodePage {
2361            width,
2362            height,
2363            kind,
2364            compression: comp,
2365            predictor: true,
2366            planar: false,
2367            tiling: None,
2368            bigtiff: false,
2369        };
2370        let bytes = encode_tiff(&page).unwrap();
2371        let d = decode_tiff(&bytes).unwrap();
2372        assert_eq!((d.width, d.height), (width, height));
2373        d.frame.planes[0].data.clone()
2374    }
2375
2376    #[test]
2377    fn encode_gray8_predictor_lzw_roundtrip() {
2378        let pixels = ramp_gray8(40, 24);
2379        let out = predictor_roundtrip(
2380            40,
2381            24,
2382            EncodePixelFormat::Gray8 { pixels: &pixels },
2383            TiffCompression::Lzw,
2384        );
2385        assert_eq!(out, pixels);
2386    }
2387
2388    #[test]
2389    fn encode_gray8_predictor_deflate_roundtrip() {
2390        let pixels = ramp_gray8(33, 17);
2391        let out = predictor_roundtrip(
2392            33,
2393            17,
2394            EncodePixelFormat::Gray8 { pixels: &pixels },
2395            TiffCompression::Deflate,
2396        );
2397        assert_eq!(out, pixels);
2398    }
2399
2400    #[test]
2401    fn encode_gray8_predictor_none_roundtrip() {
2402        // §14 ties the predictor to LZW, but the tag is orthogonal to
2403        // the compressor; Compression=1 + Predictor=2 must still
2404        // round-trip (the decoder reverses the differencing regardless).
2405        let pixels = ramp_gray8(16, 16);
2406        let out = predictor_roundtrip(
2407            16,
2408            16,
2409            EncodePixelFormat::Gray8 { pixels: &pixels },
2410            TiffCompression::None,
2411        );
2412        assert_eq!(out, pixels);
2413    }
2414
2415    #[test]
2416    fn encode_gray16_predictor_lzw_roundtrip() {
2417        let pixels = pattern_gray16(24, 20);
2418        let out = predictor_roundtrip(
2419            24,
2420            20,
2421            EncodePixelFormat::Gray16Le { pixels: &pixels },
2422            TiffCompression::Lzw,
2423        );
2424        assert_eq!(out, pixels);
2425    }
2426
2427    #[test]
2428    fn encode_gray16_predictor_deflate_roundtrip() {
2429        let pixels = pattern_gray16(15, 9);
2430        let out = predictor_roundtrip(
2431            15,
2432            9,
2433            EncodePixelFormat::Gray16Le { pixels: &pixels },
2434            TiffCompression::Deflate,
2435        );
2436        assert_eq!(out, pixels);
2437    }
2438
2439    #[test]
2440    fn encode_rgb24_predictor_lzw_roundtrip() {
2441        // §14: per-component differencing with an offset of
2442        // SamplesPerPixel (3). A pattern where R/G/B differ ensures a
2443        // plane swap or wrong offset would corrupt the round-trip.
2444        let pixels = pattern_rgb(28, 19);
2445        let out = predictor_roundtrip(
2446            28,
2447            19,
2448            EncodePixelFormat::Rgb24 { pixels: &pixels },
2449            TiffCompression::Lzw,
2450        );
2451        assert_eq!(out, pixels);
2452    }
2453
2454    #[test]
2455    fn encode_rgb24_predictor_deflate_roundtrip() {
2456        let pixels = pattern_rgb(11, 13);
2457        let out = predictor_roundtrip(
2458            11,
2459            13,
2460            EncodePixelFormat::Rgb24 { pixels: &pixels },
2461            TiffCompression::Deflate,
2462        );
2463        assert_eq!(out, pixels);
2464    }
2465
2466    #[test]
2467    fn encode_rgb24_predictor_packbits_roundtrip() {
2468        let pixels = pattern_rgb(9, 7);
2469        let out = predictor_roundtrip(
2470            9,
2471            7,
2472            EncodePixelFormat::Rgb24 { pixels: &pixels },
2473            TiffCompression::PackBits,
2474        );
2475        assert_eq!(out, pixels);
2476    }
2477
2478    #[test]
2479    fn encode_palette_predictor_roundtrip() {
2480        // Palette indices are single-component 8-bit values; §14
2481        // differencing applies as for grayscale. The decoder expands
2482        // the (reversed) indices through the colormap to Rgb24.
2483        let palette = vec![[0, 0, 0], [255, 0, 0], [0, 255, 0], [255, 255, 255]];
2484        let mut indices = Vec::with_capacity(12 * 8);
2485        for y in 0..8u32 {
2486            for x in 0..12u32 {
2487                indices.push(((x + y) & 0x3) as u8);
2488            }
2489        }
2490        let page = EncodePage {
2491            width: 12,
2492            height: 8,
2493            kind: EncodePixelFormat::Palette8 {
2494                indices: &indices,
2495                palette: &palette,
2496            },
2497            compression: TiffCompression::Lzw,
2498            predictor: true,
2499            planar: false,
2500            tiling: None,
2501            bigtiff: false,
2502        };
2503        let bytes = encode_tiff(&page).unwrap();
2504        let d = decode_tiff(&bytes).unwrap();
2505        let mut want = Vec::with_capacity(12 * 8 * 3);
2506        for &idx in &indices {
2507            want.extend_from_slice(&palette[idx as usize]);
2508        }
2509        assert_eq!(d.frame.planes[0].data, want);
2510    }
2511
2512    #[test]
2513    fn encode_predictor_emits_tag_317() {
2514        // The encoded file must carry Predictor=2 (tag 317, SHORT) so a
2515        // third-party reader reverses the differencing. Walk the
2516        // single IFD looking for the 12-byte entry whose tag is 317.
2517        let pixels = ramp_gray8(8, 8);
2518        let page = EncodePage {
2519            width: 8,
2520            height: 8,
2521            kind: EncodePixelFormat::Gray8 { pixels: &pixels },
2522            compression: TiffCompression::Lzw,
2523            predictor: true,
2524            planar: false,
2525            tiling: None,
2526            bigtiff: false,
2527        };
2528        let b = encode_tiff(&page).unwrap();
2529        let ifd_off = u32::from_le_bytes([b[4], b[5], b[6], b[7]]) as usize;
2530        let count = u16::from_le_bytes([b[ifd_off], b[ifd_off + 1]]) as usize;
2531        let mut found = None;
2532        for k in 0..count {
2533            let e = ifd_off + 2 + k * 12;
2534            let tag = u16::from_le_bytes([b[e], b[e + 1]]);
2535            if tag == TAG_PREDICTOR {
2536                let ty = u16::from_le_bytes([b[e + 2], b[e + 3]]);
2537                let val = u16::from_le_bytes([b[e + 8], b[e + 9]]);
2538                found = Some((ty, val));
2539            }
2540        }
2541        assert_eq!(found, Some((TYPE_SHORT, PREDICTOR_HORIZONTAL)));
2542
2543        // No-predictor encode must omit the tag entirely (decoder
2544        // defaults to Predictor=1).
2545        let page2 = EncodePage {
2546            width: 8,
2547            height: 8,
2548            kind: EncodePixelFormat::Gray8 { pixels: &pixels },
2549            compression: TiffCompression::Lzw,
2550            predictor: false,
2551            planar: false,
2552            tiling: None,
2553            bigtiff: false,
2554        };
2555        let b2 = encode_tiff(&page2).unwrap();
2556        let ifd2 = u32::from_le_bytes([b2[4], b2[5], b2[6], b2[7]]) as usize;
2557        let count2 = u16::from_le_bytes([b2[ifd2], b2[ifd2 + 1]]) as usize;
2558        for k in 0..count2 {
2559            let e = ifd2 + 2 + k * 12;
2560            let tag = u16::from_le_bytes([b2[e], b2[e + 1]]);
2561            assert_ne!(tag, TAG_PREDICTOR);
2562        }
2563    }
2564
2565    #[test]
2566    fn encode_predictor_rejects_bilevel() {
2567        let packed = bilevel_checkerboard(16, 8);
2568        let page = EncodePage {
2569            width: 16,
2570            height: 8,
2571            kind: EncodePixelFormat::Bilevel { pixels: &packed },
2572            compression: TiffCompression::Lzw,
2573            predictor: true,
2574            planar: false,
2575            tiling: None,
2576            bigtiff: false,
2577        };
2578        assert!(encode_tiff(&page).is_err());
2579    }
2580
2581    #[test]
2582    fn encode_predictor_rejects_ccitt() {
2583        let packed = bilevel_checkerboard(16, 8);
2584        let page = EncodePage {
2585            width: 16,
2586            height: 8,
2587            kind: EncodePixelFormat::Bilevel { pixels: &packed },
2588            compression: TiffCompression::CcittRle,
2589            predictor: true,
2590            planar: false,
2591            tiling: None,
2592            bigtiff: false,
2593        };
2594        assert!(encode_tiff(&page).is_err());
2595    }
2596
2597    #[test]
2598    fn forward_predictor_inverts_decoder_add_gray8() {
2599        // Direct unit check that forward differencing is the exact
2600        // inverse of the decoder's cumulative add for a known row.
2601        let mut row = vec![10u8, 12, 9, 9, 200, 201];
2602        let orig = row.clone();
2603        forward_horizontal_predictor(&mut row, 6, 1, 1, 8, 6).unwrap();
2604        // First sample unchanged; rest are first differences.
2605        assert_eq!(row[0], 10);
2606        assert_eq!(row[1], 12u8.wrapping_sub(10));
2607        assert_eq!(row[5], 201u8.wrapping_sub(200));
2608        // Reverse via the decoder's algorithm (left-to-right add).
2609        for x in 1..6 {
2610            row[x] = row[x].wrapping_add(row[x - 1]);
2611        }
2612        assert_eq!(row, orig);
2613    }
2614
2615    // ---- PlanarConfiguration = 2 (separate planes) encode ----
2616
2617    fn planar_roundtrip(w: u32, h: u32, compression: TiffCompression, predictor: bool) {
2618        let pixels = pattern_rgb(w, h);
2619        let page = EncodePage {
2620            width: w,
2621            height: h,
2622            kind: EncodePixelFormat::Rgb24 { pixels: &pixels },
2623            compression,
2624            predictor,
2625            planar: true,
2626            tiling: None,
2627            bigtiff: false,
2628        };
2629        let bytes = encode_tiff(&page).unwrap();
2630        let d = decode_tiff(&bytes).unwrap();
2631        assert_eq!((d.width, d.height), (w, h));
2632        // Decoder re-interleaves the planes into chunky order, so the
2633        // output must match the original chunky RGB input bit-exactly.
2634        assert_eq!(d.frame.planes[0].data, pixels);
2635    }
2636
2637    #[test]
2638    fn encode_rgb24_planar_none_roundtrip() {
2639        planar_roundtrip(20, 16, TiffCompression::None, false);
2640    }
2641
2642    #[test]
2643    fn encode_rgb24_planar_packbits_roundtrip() {
2644        planar_roundtrip(33, 9, TiffCompression::PackBits, false);
2645    }
2646
2647    #[test]
2648    fn encode_rgb24_planar_lzw_roundtrip() {
2649        planar_roundtrip(48, 24, TiffCompression::Lzw, false);
2650    }
2651
2652    #[test]
2653    fn encode_rgb24_planar_deflate_roundtrip() {
2654        planar_roundtrip(17, 31, TiffCompression::Deflate, false);
2655    }
2656
2657    #[test]
2658    fn encode_rgb24_planar_predictor_lzw_roundtrip() {
2659        // §14 + PlanarConfiguration=2: each plane is differenced
2660        // independently with an offset of one sample.
2661        planar_roundtrip(40, 20, TiffCompression::Lzw, true);
2662    }
2663
2664    #[test]
2665    fn encode_rgb24_planar_predictor_deflate_roundtrip() {
2666        planar_roundtrip(28, 28, TiffCompression::Deflate, true);
2667    }
2668
2669    /// Inspect the encoded IFD: PlanarConfiguration must read 2, and
2670    /// StripOffsets / StripByteCounts must each carry SamplesPerPixel
2671    /// (= 3) entries — the spec's "SamplesPerPixel rows and
2672    /// StripsPerImage columns" array with StripsPerImage = 1.
2673    #[test]
2674    fn encode_planar_emits_three_strips_and_config_2() {
2675        let pixels = pattern_rgb(16, 8);
2676        let page = EncodePage {
2677            width: 16,
2678            height: 8,
2679            kind: EncodePixelFormat::Rgb24 { pixels: &pixels },
2680            compression: TiffCompression::None,
2681            predictor: false,
2682            planar: true,
2683            tiling: None,
2684            bigtiff: false,
2685        };
2686        let bytes = encode_tiff(&page).unwrap();
2687
2688        // Walk the IFD by hand (II classic TIFF).
2689        assert_eq!(&bytes[0..2], b"II");
2690        let ifd_off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
2691        let count = u16::from_le_bytes([bytes[ifd_off], bytes[ifd_off + 1]]) as usize;
2692        let mut planar_cfg = None;
2693        let mut strip_offsets_count = None;
2694        let mut strip_byte_counts_count = None;
2695        for k in 0..count {
2696            let e = ifd_off + 2 + k * 12;
2697            let tag = u16::from_le_bytes([bytes[e], bytes[e + 1]]);
2698            let cnt = u32::from_le_bytes([bytes[e + 4], bytes[e + 5], bytes[e + 6], bytes[e + 7]]);
2699            match tag {
2700                TAG_PLANAR_CONFIGURATION => {
2701                    planar_cfg = Some(u16::from_le_bytes([bytes[e + 8], bytes[e + 9]]));
2702                }
2703                TAG_STRIP_OFFSETS => strip_offsets_count = Some(cnt),
2704                TAG_STRIP_BYTE_COUNTS => strip_byte_counts_count = Some(cnt),
2705                _ => {}
2706            }
2707        }
2708        assert_eq!(planar_cfg, Some(PLANAR_SEPARATE));
2709        assert_eq!(strip_offsets_count, Some(3));
2710        assert_eq!(strip_byte_counts_count, Some(3));
2711    }
2712
2713    /// `planar = true` requires a multi-sample format; the single-sample
2714    /// formats (where the spec says PlanarConfiguration is irrelevant)
2715    /// must be rejected with a precise error rather than silently
2716    /// emitting a meaningless `PlanarConfiguration = 2`.
2717    #[test]
2718    fn encode_planar_rejects_single_sample_formats() {
2719        let g = ramp_gray8(8, 8);
2720        let page = EncodePage {
2721            width: 8,
2722            height: 8,
2723            kind: EncodePixelFormat::Gray8 { pixels: &g },
2724            compression: TiffCompression::None,
2725            predictor: false,
2726            planar: true,
2727            tiling: None,
2728            bigtiff: false,
2729        };
2730        assert!(encode_tiff(&page).is_err());
2731
2732        let palette = vec![[0u8, 0, 0], [255, 255, 255]];
2733        let indices = vec![0u8; 64];
2734        let page = EncodePage {
2735            width: 8,
2736            height: 8,
2737            kind: EncodePixelFormat::Palette8 {
2738                indices: &indices,
2739                palette: &palette,
2740            },
2741            compression: TiffCompression::None,
2742            predictor: false,
2743            planar: true,
2744            tiling: None,
2745            bigtiff: false,
2746        };
2747        assert!(encode_tiff(&page).is_err());
2748    }
2749
2750    /// Chunky output stays single-strip (PlanarConfiguration = 1) when
2751    /// `planar` is off — the planar refactor must not regress the
2752    /// default layout.
2753    #[test]
2754    fn encode_chunky_still_single_strip_config_1() {
2755        let pixels = pattern_rgb(12, 6);
2756        let page = EncodePage {
2757            width: 12,
2758            height: 6,
2759            kind: EncodePixelFormat::Rgb24 { pixels: &pixels },
2760            compression: TiffCompression::None,
2761            predictor: false,
2762            planar: false,
2763            tiling: None,
2764            bigtiff: false,
2765        };
2766        let bytes = encode_tiff(&page).unwrap();
2767        let ifd_off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
2768        let count = u16::from_le_bytes([bytes[ifd_off], bytes[ifd_off + 1]]) as usize;
2769        for k in 0..count {
2770            let e = ifd_off + 2 + k * 12;
2771            let tag = u16::from_le_bytes([bytes[e], bytes[e + 1]]);
2772            let cnt = u32::from_le_bytes([bytes[e + 4], bytes[e + 5], bytes[e + 6], bytes[e + 7]]);
2773            if tag == TAG_PLANAR_CONFIGURATION {
2774                assert_eq!(
2775                    u16::from_le_bytes([bytes[e + 8], bytes[e + 9]]),
2776                    PLANAR_CHUNKY
2777                );
2778            }
2779            if tag == TAG_STRIP_OFFSETS || tag == TAG_STRIP_BYTE_COUNTS {
2780                assert_eq!(cnt, 1);
2781            }
2782        }
2783    }
2784
2785    // ---- Tiled layout (TIFF 6.0 §15) ----
2786
2787    /// Encode `kind` with `tiling` + `comp` (+ optional predictor),
2788    /// decode through our own tile-reading path, return the decoded
2789    /// first plane.
2790    fn tile_roundtrip(
2791        width: u32,
2792        height: u32,
2793        kind: EncodePixelFormat<'_>,
2794        comp: TiffCompression,
2795        tiling: (u32, u32),
2796        predictor: bool,
2797    ) -> Vec<u8> {
2798        let page = EncodePage {
2799            width,
2800            height,
2801            kind,
2802            compression: comp,
2803            predictor,
2804            planar: false,
2805            tiling: Some(tiling),
2806            bigtiff: false,
2807        };
2808        let bytes = encode_tiff(&page).unwrap();
2809        let d = decode_tiff(&bytes).unwrap();
2810        assert_eq!((d.width, d.height), (width, height));
2811        d.frame.planes[0].data.clone()
2812    }
2813
2814    #[test]
2815    fn encode_gray8_tiled_single_tile_roundtrip() {
2816        // Image exactly one tile (16x16) — single-tile grid keeps the
2817        // TileOffsets / TileByteCounts arrays inline (count = 1).
2818        let pixels = ramp_gray8(16, 16);
2819        let out = tile_roundtrip(
2820            16,
2821            16,
2822            EncodePixelFormat::Gray8 { pixels: &pixels },
2823            TiffCompression::None,
2824            (16, 16),
2825            false,
2826        );
2827        assert_eq!(out, pixels);
2828    }
2829
2830    #[test]
2831    fn encode_gray8_tiled_grid_roundtrip() {
2832        // 48x32 image with 16x16 tiles => 3x2 = 6 tiles, exact fit.
2833        let pixels = ramp_gray8(48, 32);
2834        for comp in [
2835            TiffCompression::None,
2836            TiffCompression::PackBits,
2837            TiffCompression::Lzw,
2838            TiffCompression::Deflate,
2839        ] {
2840            let out = tile_roundtrip(
2841                48,
2842                32,
2843                EncodePixelFormat::Gray8 { pixels: &pixels },
2844                comp,
2845                (16, 16),
2846                false,
2847            );
2848            assert_eq!(out, pixels, "compression {comp:?}");
2849        }
2850    }
2851
2852    #[test]
2853    fn encode_gray8_tiled_edge_padding_roundtrip() {
2854        // 40x20 image with 16x16 tiles => 3x2 grid, right column and
2855        // bottom row are partial (40 = 2*16 + 8, 20 = 16 + 4). The
2856        // padded boundary samples must be ignored on decode, so the
2857        // visible region round-trips exactly.
2858        let pixels = ramp_gray8(40, 20);
2859        let out = tile_roundtrip(
2860            40,
2861            20,
2862            EncodePixelFormat::Gray8 { pixels: &pixels },
2863            TiffCompression::Lzw,
2864            (16, 16),
2865            false,
2866        );
2867        assert_eq!(out, pixels);
2868    }
2869
2870    #[test]
2871    fn encode_gray16_tiled_roundtrip() {
2872        let pixels = pattern_gray16(48, 32);
2873        let out = tile_roundtrip(
2874            48,
2875            32,
2876            EncodePixelFormat::Gray16Le { pixels: &pixels },
2877            TiffCompression::Deflate,
2878            (16, 16),
2879            false,
2880        );
2881        assert_eq!(out, pixels);
2882    }
2883
2884    #[test]
2885    fn encode_rgb24_tiled_roundtrip() {
2886        // Non-square tile (32 wide, 16 tall) with edge padding on both
2887        // axes: 50x30 => 2x2 grid with partial right/bottom tiles.
2888        let pixels = pattern_rgb(50, 30);
2889        for comp in [
2890            TiffCompression::None,
2891            TiffCompression::PackBits,
2892            TiffCompression::Lzw,
2893            TiffCompression::Deflate,
2894        ] {
2895            let out = tile_roundtrip(
2896                50,
2897                30,
2898                EncodePixelFormat::Rgb24 { pixels: &pixels },
2899                comp,
2900                (32, 16),
2901                false,
2902            );
2903            assert_eq!(out, pixels, "compression {comp:?}");
2904        }
2905    }
2906
2907    #[test]
2908    fn encode_rgb24_tiled_predictor_roundtrip() {
2909        // §14 predictor applied per-tile must reverse exactly through
2910        // the decoder's per-tile cumulative add, including across the
2911        // padded boundary tiles.
2912        let pixels = pattern_rgb(48, 32);
2913        let out = tile_roundtrip(
2914            48,
2915            32,
2916            EncodePixelFormat::Rgb24 { pixels: &pixels },
2917            TiffCompression::Lzw,
2918            (16, 16),
2919            true,
2920        );
2921        assert_eq!(out, pixels);
2922    }
2923
2924    #[test]
2925    fn encode_gray8_tiled_predictor_edge_roundtrip() {
2926        // Predictor + edge padding on a single-component image.
2927        let pixels = ramp_gray8(40, 20);
2928        let out = tile_roundtrip(
2929            40,
2930            20,
2931            EncodePixelFormat::Gray8 { pixels: &pixels },
2932            TiffCompression::Deflate,
2933            (16, 16),
2934            true,
2935        );
2936        assert_eq!(out, pixels);
2937    }
2938
2939    #[test]
2940    fn encode_palette_tiled_roundtrip() {
2941        let palette = vec![[0, 0, 0], [255, 0, 0], [0, 255, 0], [255, 255, 255]];
2942        let mut indices = Vec::with_capacity(40 * 20);
2943        for y in 0..20u32 {
2944            for x in 0..40u32 {
2945                indices.push(((x + y) & 0x3) as u8);
2946            }
2947        }
2948        let page = EncodePage {
2949            width: 40,
2950            height: 20,
2951            kind: EncodePixelFormat::Palette8 {
2952                indices: &indices,
2953                palette: &palette,
2954            },
2955            compression: TiffCompression::Lzw,
2956            predictor: false,
2957            planar: false,
2958            tiling: Some((16, 16)),
2959            bigtiff: false,
2960        };
2961        let bytes = encode_tiff(&page).unwrap();
2962        let d = decode_tiff(&bytes).unwrap();
2963        let mut want = Vec::with_capacity(40 * 20 * 3);
2964        for &idx in &indices {
2965            want.extend_from_slice(&palette[idx as usize]);
2966        }
2967        assert_eq!(d.frame.planes[0].data, want);
2968    }
2969
2970    #[test]
2971    fn encode_tiled_emits_tile_tags_not_strip_tags() {
2972        // A tiled IFD must carry TileWidth/TileLength/TileOffsets/
2973        // TileByteCounts and NOT StripOffsets/RowsPerStrip/
2974        // StripByteCounts (§15: the tile fields "replace" the strip
2975        // fields; "Do not use both … in the same TIFF file").
2976        let pixels = ramp_gray8(48, 32);
2977        let page = EncodePage {
2978            width: 48,
2979            height: 32,
2980            kind: EncodePixelFormat::Gray8 { pixels: &pixels },
2981            compression: TiffCompression::None,
2982            predictor: false,
2983            planar: false,
2984            tiling: Some((16, 16)),
2985            bigtiff: false,
2986        };
2987        let b = encode_tiff(&page).unwrap();
2988        let ifd_off = u32::from_le_bytes([b[4], b[5], b[6], b[7]]) as usize;
2989        let count = u16::from_le_bytes([b[ifd_off], b[ifd_off + 1]]) as usize;
2990        let mut seen = std::collections::HashMap::new();
2991        for k in 0..count {
2992            let e = ifd_off + 2 + k * 12;
2993            let tag = u16::from_le_bytes([b[e], b[e + 1]]);
2994            let cnt = u32::from_le_bytes([b[e + 4], b[e + 5], b[e + 6], b[e + 7]]);
2995            seen.insert(tag, cnt);
2996        }
2997        // No strip tags.
2998        assert!(!seen.contains_key(&TAG_STRIP_OFFSETS));
2999        assert!(!seen.contains_key(&TAG_STRIP_BYTE_COUNTS));
3000        assert!(!seen.contains_key(&TAG_ROWS_PER_STRIP));
3001        // Tile tags present; TilesPerImage = 3*2 = 6.
3002        assert!(seen.contains_key(&TAG_TILE_WIDTH));
3003        assert!(seen.contains_key(&TAG_TILE_LENGTH));
3004        assert_eq!(seen.get(&TAG_TILE_OFFSETS), Some(&6));
3005        assert_eq!(seen.get(&TAG_TILE_BYTE_COUNTS), Some(&6));
3006        // Ascending tag order across the whole IFD.
3007        let mut prev = 0u16;
3008        for k in 0..count {
3009            let e = ifd_off + 2 + k * 12;
3010            let tag = u16::from_le_bytes([b[e], b[e + 1]]);
3011            assert!(tag > prev, "tag {tag} not after {prev}");
3012            prev = tag;
3013        }
3014    }
3015
3016    #[test]
3017    fn encode_tiling_rejects_non_multiple_of_16() {
3018        let pixels = ramp_gray8(32, 32);
3019        let page = EncodePage {
3020            width: 32,
3021            height: 32,
3022            kind: EncodePixelFormat::Gray8 { pixels: &pixels },
3023            compression: TiffCompression::None,
3024            predictor: false,
3025            planar: false,
3026            tiling: Some((20, 16)),
3027            bigtiff: false,
3028        };
3029        assert!(encode_tiff(&page).is_err());
3030    }
3031
3032    #[test]
3033    fn encode_tiling_rejects_bilevel() {
3034        let packed = bilevel_checkerboard(32, 16);
3035        let page = EncodePage {
3036            width: 32,
3037            height: 16,
3038            kind: EncodePixelFormat::Bilevel { pixels: &packed },
3039            compression: TiffCompression::None,
3040            predictor: false,
3041            planar: false,
3042            tiling: Some((16, 16)),
3043            bigtiff: false,
3044        };
3045        assert!(encode_tiff(&page).is_err());
3046    }
3047
3048    #[test]
3049    fn encode_tiling_rejects_ccitt() {
3050        let packed = bilevel_checkerboard(32, 16);
3051        let page = EncodePage {
3052            width: 32,
3053            height: 16,
3054            kind: EncodePixelFormat::Bilevel { pixels: &packed },
3055            compression: TiffCompression::CcittRle,
3056            predictor: false,
3057            planar: false,
3058            tiling: Some((16, 16)),
3059            bigtiff: false,
3060        };
3061        assert!(encode_tiff(&page).is_err());
3062    }
3063
3064    #[test]
3065    fn encode_tiling_planar_rgb24_roundtrips() {
3066        // Planar + tiled Rgb24 (TIFF 6.0 §15 + §"PlanarConfiguration"):
3067        // one tile grid per component plane, plane-major TileOffsets.
3068        // Encodes and self-decodes back to the source pixels.
3069        let pixels = pattern_rgb(32, 32);
3070        let page = EncodePage {
3071            width: 32,
3072            height: 32,
3073            kind: EncodePixelFormat::Rgb24 { pixels: &pixels },
3074            compression: TiffCompression::Lzw,
3075            predictor: false,
3076            planar: true,
3077            tiling: Some((16, 16)),
3078            bigtiff: false,
3079        };
3080        let bytes = encode_tiff(&page).expect("planar tiled encode");
3081        let d = decode_tiff(&bytes).expect("planar tiled decode");
3082        assert_eq!((d.width, d.height), (32, 32));
3083        assert_eq!(d.frame.planes[0].data, pixels);
3084    }
3085
3086    #[test]
3087    fn encode_tiled_multi_page_chain() {
3088        // Tiled pages must chain correctly in a multi-IFD file, mixed
3089        // with strip pages.
3090        let p1 = ramp_gray8(48, 32);
3091        let p2 = pattern_rgb(16, 16);
3092        let pages = vec![
3093            EncodePage {
3094                width: 48,
3095                height: 32,
3096                kind: EncodePixelFormat::Gray8 { pixels: &p1 },
3097                compression: TiffCompression::Lzw,
3098                predictor: false,
3099                planar: false,
3100                tiling: Some((16, 16)),
3101                bigtiff: false,
3102            },
3103            EncodePage {
3104                width: 16,
3105                height: 16,
3106                kind: EncodePixelFormat::Rgb24 { pixels: &p2 },
3107                compression: TiffCompression::None,
3108                predictor: false,
3109                planar: false,
3110                tiling: None,
3111                bigtiff: false,
3112            },
3113        ];
3114        let bytes = encode_tiff_multi(&pages).unwrap();
3115        let imgs = crate::decoder::decode_tiff_all(&bytes).unwrap();
3116        assert_eq!(imgs.len(), 2);
3117        assert_eq!(imgs[0].planes[0].data, p1);
3118        assert_eq!(imgs[1].planes[0].data, p2);
3119    }
3120
3121    // ----------------------------------------------------------------
3122    // CIELab encode (TIFF 6.0 §23, PhotometricInterpretation = 8)
3123    // ----------------------------------------------------------------
3124    //
3125    // The encoder writes the caller-supplied L*/a*/b* bytes through
3126    // verbatim — the decoder takes them back off disk verbatim too,
3127    // so a self-roundtrip can compare on-disk-bytes-in vs
3128    // bytes-the-decoder-saw at the strip / tile layer. The
3129    // colourimetric Lab → Rgb24 conversion the decoder applies *after*
3130    // that is exercised separately by `tests/decode_cielab.rs`.
3131
3132    /// Pack a logical (L%, a, b) where L is the 0..100 perceptual scale
3133    /// and a, b are -127..127, into the three on-disk bytes per §23.
3134    fn pack_lab_byte(l_pct: f64, a_signed: i32, b_signed: i32) -> [u8; 3] {
3135        let l_byte = (l_pct * 255.0 / 100.0).round().clamp(0.0, 255.0) as u8;
3136        [l_byte, (a_signed as i8) as u8, (b_signed as i8) as u8]
3137    }
3138
3139    /// Build a 3-sample (L*, a*, b*) raster as a deterministic mix of
3140    /// the four chromatic primaries plus the neutral gradient.
3141    fn lab_pattern_3sample(w: u32, h: u32) -> Vec<u8> {
3142        let mut v = Vec::with_capacity((w * h * 3) as usize);
3143        for y in 0..h {
3144            for x in 0..w {
3145                // L* swings 0..100 across the row, a* sweeps -127..127
3146                // across the column, b* picks up alternate sign rows.
3147                let l_pct = (x as f64) * 100.0 / (w as f64).max(1.0);
3148                let a_signed = -127 + (2 * 127 * (y as i32) / (h as i32).max(1));
3149                let b_signed = if (x ^ y) & 1 == 0 { 50 } else { -50 };
3150                v.extend_from_slice(&pack_lab_byte(l_pct, a_signed, b_signed));
3151            }
3152        }
3153        v
3154    }
3155
3156    /// 1-sample L*-only ramp.
3157    fn lab_l_ramp(w: u32, h: u32) -> Vec<u8> {
3158        let mut v = Vec::with_capacity((w * h) as usize);
3159        for y in 0..h {
3160            for x in 0..w {
3161                v.push(((x.wrapping_add(y)) & 0xFF) as u8);
3162            }
3163        }
3164        v
3165    }
3166
3167    /// Helper: take the public-API decoded (Lab → Rgb24) output of a
3168    /// CIELab fixture as the round-trip "ground truth" and check that
3169    /// the same source bytes round-trip through both the hand-built
3170    /// classic fixture path and our `encode_tiff(CieLab8)`. If both
3171    /// produce identical Rgb24 outputs, the encoder is writing the
3172    /// strip / IFD / photometric the decoder is expecting.
3173    fn decode_3sample_cielab(pixels: &[u8], w: u32, h: u32) -> Vec<u8> {
3174        // Build the same classic-TIFF the decode-side tests use,
3175        // independently of our encoder, to get the canonical Rgb24 the
3176        // decoder produces from a verbatim L*/a*/b* strip. The encoder
3177        // path's Rgb24 must match this.
3178        let row_bytes = (w as u64) * 3;
3179        let strip_bytes = row_bytes * (h as u64);
3180        assert_eq!(pixels.len() as u64, strip_bytes);
3181        let num_entries: u16 = 8;
3182        let ifd_offset: u32 = 8;
3183        let ifd_size: u32 = 2 + (num_entries as u32) * 12 + 4;
3184        let bps_blob_bytes: u32 = 3 * 2;
3185        let blobs_offset: u32 = ifd_offset + ifd_size;
3186        let bps_off = blobs_offset;
3187        let pixels_off = bps_off + bps_blob_bytes;
3188        let mut buf: Vec<u8> = Vec::new();
3189        buf.extend_from_slice(b"II");
3190        buf.extend_from_slice(&42u16.to_le_bytes());
3191        buf.extend_from_slice(&ifd_offset.to_le_bytes());
3192        buf.extend_from_slice(&num_entries.to_le_bytes());
3193        let push = |buf: &mut Vec<u8>, tag: u16, ft: u16, count: u32, v: [u8; 4]| {
3194            buf.extend_from_slice(&tag.to_le_bytes());
3195            buf.extend_from_slice(&ft.to_le_bytes());
3196            buf.extend_from_slice(&count.to_le_bytes());
3197            buf.extend_from_slice(&v);
3198        };
3199        push(&mut buf, 256, 4, 1, w.to_le_bytes());
3200        push(&mut buf, 257, 4, 1, h.to_le_bytes());
3201        push(&mut buf, 258, 3, 3, bps_off.to_le_bytes());
3202        let mut comp = [0u8; 4];
3203        comp[..2].copy_from_slice(&1u16.to_le_bytes());
3204        push(&mut buf, 259, 3, 1, comp);
3205        let mut ph = [0u8; 4];
3206        ph[..2].copy_from_slice(&8u16.to_le_bytes());
3207        push(&mut buf, 262, 3, 1, ph);
3208        push(&mut buf, 273, 4, 1, pixels_off.to_le_bytes());
3209        let mut spp = [0u8; 4];
3210        spp[..2].copy_from_slice(&3u16.to_le_bytes());
3211        push(&mut buf, 277, 3, 1, spp);
3212        push(&mut buf, 279, 4, 1, (strip_bytes as u32).to_le_bytes());
3213        buf.extend_from_slice(&0u32.to_le_bytes());
3214        for _ in 0..3u16 {
3215            buf.extend_from_slice(&8u16.to_le_bytes());
3216        }
3217        buf.extend_from_slice(pixels);
3218        decode_tiff(&buf).unwrap().frame.planes[0].data.clone()
3219    }
3220
3221    fn decode_1sample_cielab(pixels: &[u8], w: u32, h: u32) -> Vec<u8> {
3222        let strip_bytes = (w as u64) * (h as u64);
3223        assert_eq!(pixels.len() as u64, strip_bytes);
3224        let num_entries: u16 = 8;
3225        let ifd_offset: u32 = 8;
3226        let ifd_size: u32 = 2 + (num_entries as u32) * 12 + 4;
3227        let blobs_offset: u32 = ifd_offset + ifd_size;
3228        let pixels_off = blobs_offset;
3229        let mut buf: Vec<u8> = Vec::new();
3230        buf.extend_from_slice(b"II");
3231        buf.extend_from_slice(&42u16.to_le_bytes());
3232        buf.extend_from_slice(&ifd_offset.to_le_bytes());
3233        buf.extend_from_slice(&num_entries.to_le_bytes());
3234        let push = |buf: &mut Vec<u8>, tag: u16, ft: u16, count: u32, v: [u8; 4]| {
3235            buf.extend_from_slice(&tag.to_le_bytes());
3236            buf.extend_from_slice(&ft.to_le_bytes());
3237            buf.extend_from_slice(&count.to_le_bytes());
3238            buf.extend_from_slice(&v);
3239        };
3240        push(&mut buf, 256, 4, 1, w.to_le_bytes());
3241        push(&mut buf, 257, 4, 1, h.to_le_bytes());
3242        let mut bps = [0u8; 4];
3243        bps[..2].copy_from_slice(&8u16.to_le_bytes());
3244        push(&mut buf, 258, 3, 1, bps);
3245        let mut comp = [0u8; 4];
3246        comp[..2].copy_from_slice(&1u16.to_le_bytes());
3247        push(&mut buf, 259, 3, 1, comp);
3248        let mut ph = [0u8; 4];
3249        ph[..2].copy_from_slice(&8u16.to_le_bytes());
3250        push(&mut buf, 262, 3, 1, ph);
3251        push(&mut buf, 273, 4, 1, pixels_off.to_le_bytes());
3252        let mut spp = [0u8; 4];
3253        spp[..2].copy_from_slice(&1u16.to_le_bytes());
3254        push(&mut buf, 277, 3, 1, spp);
3255        push(&mut buf, 279, 4, 1, (strip_bytes as u32).to_le_bytes());
3256        buf.extend_from_slice(&0u32.to_le_bytes());
3257        buf.extend_from_slice(pixels);
3258        decode_tiff(&buf).unwrap().frame.planes[0].data.clone()
3259    }
3260
3261    #[test]
3262    fn encode_cielab8_uncompressed_roundtrip() {
3263        // 3-sample (L*, a*, b*) at 8 bits each: encoder must write
3264        // PhotometricInterpretation = 8 + SamplesPerPixel = 3 +
3265        // BitsPerSample = [8,8,8], so the decoder takes the strip bytes
3266        // through the §23 colourimetric pipeline. Self-roundtrip checks
3267        // the output matches the hand-built fixture's decode.
3268        let pixels = lab_pattern_3sample(8, 8);
3269        let page = EncodePage {
3270            width: 8,
3271            height: 8,
3272            kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3273            compression: TiffCompression::None,
3274            predictor: false,
3275            planar: false,
3276            tiling: None,
3277            bigtiff: false,
3278        };
3279        let bytes = encode_tiff(&page).unwrap();
3280        let d = decode_tiff(&bytes).unwrap();
3281        assert_eq!((d.width, d.height), (8, 8));
3282        assert_eq!(d.pixel_format, TiffPixelFormat::Rgb24);
3283        let want = decode_3sample_cielab(&pixels, 8, 8);
3284        assert_eq!(d.frame.planes[0].data, want);
3285    }
3286
3287    #[test]
3288    fn encode_cielab8_compressors_match_uncompressed() {
3289        // PackBits / LZW / Deflate must all produce decoder output
3290        // identical to the uncompressed encode (lossless byte-aligned
3291        // compressors, photometric-agnostic).
3292        let pixels = lab_pattern_3sample(16, 8);
3293        let baseline = {
3294            let page = EncodePage {
3295                width: 16,
3296                height: 8,
3297                kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3298                compression: TiffCompression::None,
3299                predictor: false,
3300                planar: false,
3301                tiling: None,
3302                bigtiff: false,
3303            };
3304            decode_tiff(&encode_tiff(&page).unwrap())
3305                .unwrap()
3306                .frame
3307                .planes[0]
3308                .data
3309                .clone()
3310        };
3311        for c in [
3312            TiffCompression::PackBits,
3313            TiffCompression::Lzw,
3314            TiffCompression::Deflate,
3315        ] {
3316            let page = EncodePage {
3317                width: 16,
3318                height: 8,
3319                kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3320                compression: c,
3321                predictor: false,
3322                planar: false,
3323                tiling: None,
3324                bigtiff: false,
3325            };
3326            let d = decode_tiff(&encode_tiff(&page).unwrap()).unwrap();
3327            assert_eq!(d.frame.planes[0].data, baseline, "compressor {:?}", c);
3328        }
3329    }
3330
3331    #[test]
3332    fn encode_cielab8_predictor_composes() {
3333        // Predictor=2 must round-trip on chunky 3-sample CIELab —
3334        // the decoder undoes the per-component differencing with
3335        // SamplesPerPixel = 3, identical to Rgb24.
3336        let pixels = lab_pattern_3sample(20, 12);
3337        let no_pred = {
3338            let page = EncodePage {
3339                width: 20,
3340                height: 12,
3341                kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3342                compression: TiffCompression::Lzw,
3343                predictor: false,
3344                planar: false,
3345                tiling: None,
3346                bigtiff: false,
3347            };
3348            decode_tiff(&encode_tiff(&page).unwrap())
3349                .unwrap()
3350                .frame
3351                .planes[0]
3352                .data
3353                .clone()
3354        };
3355        let with_pred = {
3356            let page = EncodePage {
3357                width: 20,
3358                height: 12,
3359                kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3360                compression: TiffCompression::Lzw,
3361                predictor: true,
3362                planar: false,
3363                tiling: None,
3364                bigtiff: false,
3365            };
3366            decode_tiff(&encode_tiff(&page).unwrap())
3367                .unwrap()
3368                .frame
3369                .planes[0]
3370                .data
3371                .clone()
3372        };
3373        assert_eq!(no_pred, with_pred);
3374    }
3375
3376    #[test]
3377    fn encode_cielab8_planar_composes() {
3378        // PlanarConfiguration = 2 splits L* / a* / b* into three
3379        // single-component planes (§"PlanarConfiguration"). The
3380        // re-interleaved decode must match the chunky path.
3381        let pixels = lab_pattern_3sample(16, 8);
3382        let chunky = {
3383            let page = EncodePage {
3384                width: 16,
3385                height: 8,
3386                kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3387                compression: TiffCompression::Deflate,
3388                predictor: false,
3389                planar: false,
3390                tiling: None,
3391                bigtiff: false,
3392            };
3393            decode_tiff(&encode_tiff(&page).unwrap())
3394                .unwrap()
3395                .frame
3396                .planes[0]
3397                .data
3398                .clone()
3399        };
3400        let planar = {
3401            let page = EncodePage {
3402                width: 16,
3403                height: 8,
3404                kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3405                compression: TiffCompression::Deflate,
3406                predictor: false,
3407                planar: true,
3408                tiling: None,
3409                bigtiff: false,
3410            };
3411            decode_tiff(&encode_tiff(&page).unwrap())
3412                .unwrap()
3413                .frame
3414                .planes[0]
3415                .data
3416                .clone()
3417        };
3418        assert_eq!(chunky, planar);
3419    }
3420
3421    #[test]
3422    fn encode_cielab8_tiled_composes() {
3423        // Tiled §15 chunky write — `tiffcp` round-trip pattern, applied
3424        // to L*/a*/b* via the byte-aligned tile splitter.
3425        let pixels = lab_pattern_3sample(32, 32);
3426        let strip = {
3427            let page = EncodePage {
3428                width: 32,
3429                height: 32,
3430                kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3431                compression: TiffCompression::Lzw,
3432                predictor: false,
3433                planar: false,
3434                tiling: None,
3435                bigtiff: false,
3436            };
3437            decode_tiff(&encode_tiff(&page).unwrap())
3438                .unwrap()
3439                .frame
3440                .planes[0]
3441                .data
3442                .clone()
3443        };
3444        let tiled = {
3445            let page = EncodePage {
3446                width: 32,
3447                height: 32,
3448                kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3449                compression: TiffCompression::Lzw,
3450                predictor: false,
3451                planar: false,
3452                tiling: Some((16, 16)),
3453                bigtiff: false,
3454            };
3455            decode_tiff(&encode_tiff(&page).unwrap())
3456                .unwrap()
3457                .frame
3458                .planes[0]
3459                .data
3460                .clone()
3461        };
3462        assert_eq!(strip, tiled);
3463    }
3464
3465    #[test]
3466    fn encode_cielab8_bigtiff_composes() {
3467        // BigTIFF: BitsPerSample[3] should now sit inline in the
3468        // widened 8-byte value/offset slot. Self-roundtrip the same as
3469        // the classic path.
3470        let pixels = lab_pattern_3sample(8, 8);
3471        let page = EncodePage {
3472            width: 8,
3473            height: 8,
3474            kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3475            compression: TiffCompression::Deflate,
3476            predictor: false,
3477            planar: false,
3478            tiling: None,
3479            bigtiff: true,
3480        };
3481        let bytes = encode_tiff(&page).unwrap();
3482        // BigTIFF magic 43.
3483        assert_eq!(&bytes[..2], b"II");
3484        assert_eq!(u16::from_le_bytes([bytes[2], bytes[3]]), 43);
3485        let d = decode_tiff(&bytes).unwrap();
3486        let want = decode_3sample_cielab(&pixels, 8, 8);
3487        assert_eq!(d.frame.planes[0].data, want);
3488    }
3489
3490    #[test]
3491    fn encode_cielab8_rejects_ccitt() {
3492        // CCITT is bilevel-only per §10 / §11; CieLab8 is 3-sample
3493        // 8-bit, so the bilevel-input gate must reject.
3494        let pixels = lab_pattern_3sample(8, 8);
3495        let page = EncodePage {
3496            width: 8,
3497            height: 8,
3498            kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3499            compression: TiffCompression::CcittRle,
3500            predictor: false,
3501            planar: false,
3502            tiling: None,
3503            bigtiff: false,
3504        };
3505        let err = encode_tiff(&page).unwrap_err();
3506        assert!(format!("{err}").contains("CCITT"));
3507    }
3508
3509    #[test]
3510    fn encode_cielab8_wrong_buffer_size_rejected() {
3511        // 8x8 wants 192 bytes (3 * 64); pass 100 to exercise the size
3512        // validator.
3513        let bad = vec![0u8; 100];
3514        let page = EncodePage {
3515            width: 8,
3516            height: 8,
3517            kind: EncodePixelFormat::CieLab8 { pixels: &bad },
3518            compression: TiffCompression::None,
3519            predictor: false,
3520            planar: false,
3521            tiling: None,
3522            bigtiff: false,
3523        };
3524        let err = encode_tiff(&page).unwrap_err();
3525        assert!(format!("{err}").contains("CieLab8"));
3526    }
3527
3528    #[test]
3529    fn encode_cielab_l8_roundtrip() {
3530        // 1-sample L*-only — §23 "1 implies L* only, for monochrome
3531        // data". Decoder must produce Gray8 matching the hand-built
3532        // fixture.
3533        let pixels = lab_l_ramp(8, 4);
3534        let page = EncodePage {
3535            width: 8,
3536            height: 4,
3537            kind: EncodePixelFormat::CieLabL8 { pixels: &pixels },
3538            compression: TiffCompression::None,
3539            predictor: false,
3540            planar: false,
3541            tiling: None,
3542            bigtiff: false,
3543        };
3544        let bytes = encode_tiff(&page).unwrap();
3545        let d = decode_tiff(&bytes).unwrap();
3546        assert_eq!((d.width, d.height), (8, 4));
3547        assert_eq!(d.pixel_format, TiffPixelFormat::Gray8);
3548        let want = decode_1sample_cielab(&pixels, 8, 4);
3549        assert_eq!(d.frame.planes[0].data, want);
3550    }
3551
3552    #[test]
3553    fn encode_cielab_l8_rejects_planar() {
3554        // SamplesPerPixel = 1 → §"PlanarConfiguration" "irrelevant" →
3555        // rejected (mirrors Gray8 / Gray16Le / Palette8 / Bilevel).
3556        let pixels = lab_l_ramp(8, 4);
3557        let page = EncodePage {
3558            width: 8,
3559            height: 4,
3560            kind: EncodePixelFormat::CieLabL8 { pixels: &pixels },
3561            compression: TiffCompression::None,
3562            predictor: false,
3563            planar: true,
3564            tiling: None,
3565            bigtiff: false,
3566        };
3567        let err = encode_tiff(&page).unwrap_err();
3568        assert!(format!("{err}").contains("PlanarConfiguration"));
3569    }
3570
3571    #[test]
3572    fn encode_cielab_writes_photometric_8() {
3573        // Decode the encoder's output through a byte-level IFD walker
3574        // (independent of our decoder) and confirm
3575        // PhotometricInterpretation = 8 lands in tag 262.
3576        let pixels = lab_pattern_3sample(8, 8);
3577        let page = EncodePage {
3578            width: 8,
3579            height: 8,
3580            kind: EncodePixelFormat::CieLab8 { pixels: &pixels },
3581            compression: TiffCompression::None,
3582            predictor: false,
3583            planar: false,
3584            tiling: None,
3585            bigtiff: false,
3586        };
3587        let bytes = encode_tiff(&page).unwrap();
3588        let ifd_off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
3589        let count = u16::from_le_bytes([bytes[ifd_off], bytes[ifd_off + 1]]) as usize;
3590        let mut found = None;
3591        for k in 0..count {
3592            let entry_off = ifd_off + 2 + k * 12;
3593            let tag = u16::from_le_bytes([bytes[entry_off], bytes[entry_off + 1]]);
3594            if tag == 262 {
3595                let val = u16::from_le_bytes([bytes[entry_off + 8], bytes[entry_off + 9]]);
3596                found = Some(val);
3597            }
3598        }
3599        assert_eq!(found, Some(8), "expected PhotometricInterpretation = 8");
3600    }
3601
3602    // ---- CMYK (TIFF 6.0 §16) encode tests ---------------------------
3603    //
3604    // §16 fixes the on-disk bit layout: 4 chunky samples per pixel
3605    // ordered (C, M, Y, K), each unsigned 8-bit with `0` = 0 % ink
3606    // and `255` = 100 % ink. The encoder writes those bytes through
3607    // verbatim, so a self-roundtrip compares strip-bytes-in vs
3608    // strip-bytes-out at the strip layer; the §16 additive-RGB
3609    // collapse the decoder performs (`build_rgb24_from_cmyk` in
3610    // `src/decoder.rs`) is exercised separately by tests against the
3611    // hand-built classic fixture below.
3612
3613    /// Build a deterministic CMYK raster that exercises pure ink
3614    /// channels, neutrals, and a black-only column so the encoder
3615    /// sees a non-trivial value distribution per component.
3616    fn cmyk_pattern_4sample(w: u32, h: u32) -> Vec<u8> {
3617        let mut v = Vec::with_capacity((w * h * 4) as usize);
3618        for y in 0..h {
3619            for x in 0..w {
3620                // C sweeps the row, M sweeps the column, Y is the
3621                // XOR plane, K is a per-row ramp. Each of the four
3622                // components gets a meaningful 0..=255 distribution.
3623                let c = ((x.wrapping_mul(7)) & 0xFF) as u8;
3624                let m = ((y.wrapping_mul(11)) & 0xFF) as u8;
3625                let y_byte = ((x ^ y).wrapping_mul(13) & 0xFF) as u8;
3626                let k = ((y * 255) / h.max(1).saturating_sub(1).max(1)) as u8;
3627                v.extend_from_slice(&[c, m, y_byte, k]);
3628            }
3629        }
3630        v
3631    }
3632
3633    /// Hand-build a classic chunky CMYK TIFF (no encoder), decode it
3634    /// through `decode_tiff`, and return the resulting Rgb24 plane.
3635    /// This anchors what an §16-conforming CMYK page is supposed to
3636    /// look like off-disk so the encoder output can be checked
3637    /// against the *same* decoded image. Mirrors the
3638    /// `decode_3sample_cielab` helper above.
3639    fn decode_cmyk_4sample(pixels: &[u8], w: u32, h: u32) -> Vec<u8> {
3640        let row_bytes = (w as u64) * 4;
3641        let strip_bytes = row_bytes * (h as u64);
3642        assert_eq!(pixels.len() as u64, strip_bytes);
3643        // 254 + 256/257/258/259/262/273/277/279 + 332/334 = 11 entries.
3644        let num_entries: u16 = 11;
3645        let ifd_offset: u32 = 8;
3646        let ifd_size: u32 = 2 + (num_entries as u32) * 12 + 4;
3647        let bps_blob_bytes: u32 = 4 * 2; // four SHORT entries — spills out-of-line in classic TIFF (8 > 4)
3648        let blobs_offset: u32 = ifd_offset + ifd_size;
3649        let bps_off = blobs_offset;
3650        let pixels_off = bps_off + bps_blob_bytes;
3651        let mut buf: Vec<u8> = Vec::new();
3652        buf.extend_from_slice(b"II");
3653        buf.extend_from_slice(&42u16.to_le_bytes());
3654        buf.extend_from_slice(&ifd_offset.to_le_bytes());
3655        buf.extend_from_slice(&num_entries.to_le_bytes());
3656        let push = |buf: &mut Vec<u8>, tag: u16, ft: u16, count: u32, v: [u8; 4]| {
3657            buf.extend_from_slice(&tag.to_le_bytes());
3658            buf.extend_from_slice(&ft.to_le_bytes());
3659            buf.extend_from_slice(&count.to_le_bytes());
3660            buf.extend_from_slice(&v);
3661        };
3662        // 254 NewSubfileType = 0.
3663        push(&mut buf, 254, 4, 1, 0u32.to_le_bytes());
3664        // 256 ImageWidth, 257 ImageLength (LONG).
3665        push(&mut buf, 256, 4, 1, w.to_le_bytes());
3666        push(&mut buf, 257, 4, 1, h.to_le_bytes());
3667        // 258 BitsPerSample = [8,8,8,8] SHORT (spills out-of-line).
3668        push(&mut buf, 258, 3, 4, bps_off.to_le_bytes());
3669        // 259 Compression = 1.
3670        let mut comp = [0u8; 4];
3671        comp[..2].copy_from_slice(&1u16.to_le_bytes());
3672        push(&mut buf, 259, 3, 1, comp);
3673        // 262 PhotometricInterpretation = 5 (CMYK).
3674        let mut ph = [0u8; 4];
3675        ph[..2].copy_from_slice(&5u16.to_le_bytes());
3676        push(&mut buf, 262, 3, 1, ph);
3677        // 273 StripOffsets (LONG, inline for 1 strip).
3678        push(&mut buf, 273, 4, 1, pixels_off.to_le_bytes());
3679        // 277 SamplesPerPixel = 4.
3680        let mut spp = [0u8; 4];
3681        spp[..2].copy_from_slice(&4u16.to_le_bytes());
3682        push(&mut buf, 277, 3, 1, spp);
3683        // 279 StripByteCounts (LONG, inline).
3684        push(&mut buf, 279, 4, 1, (strip_bytes as u32).to_le_bytes());
3685        // 332 InkSet = 1 (CMYK).
3686        let mut ink = [0u8; 4];
3687        ink[..2].copy_from_slice(&1u16.to_le_bytes());
3688        push(&mut buf, 332, 3, 1, ink);
3689        // 334 NumberOfInks = 4.
3690        let mut nink = [0u8; 4];
3691        nink[..2].copy_from_slice(&4u16.to_le_bytes());
3692        push(&mut buf, 334, 3, 1, nink);
3693        // Next IFD = 0.
3694        buf.extend_from_slice(&0u32.to_le_bytes());
3695        // BitsPerSample blob (four SHORTs = 8 bytes).
3696        for _ in 0..4u16 {
3697            buf.extend_from_slice(&8u16.to_le_bytes());
3698        }
3699        buf.extend_from_slice(pixels);
3700        decode_tiff(&buf).unwrap().frame.planes[0].data.clone()
3701    }
3702
3703    #[test]
3704    fn encode_cmyk32_uncompressed_roundtrip() {
3705        // 4-sample chunky (C, M, Y, K) at 8 bits each — encoder must
3706        // write PhotometricInterpretation = 5, SamplesPerPixel = 4,
3707        // BitsPerSample = [8,8,8,8], so the decoder takes the strip
3708        // bytes through the §16 additive-RGB collapse. The encoder
3709        // output's Rgb24 must match the hand-built fixture's decode.
3710        let pixels = cmyk_pattern_4sample(8, 8);
3711        let page = EncodePage {
3712            width: 8,
3713            height: 8,
3714            kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3715            compression: TiffCompression::None,
3716            predictor: false,
3717            planar: false,
3718            tiling: None,
3719            bigtiff: false,
3720        };
3721        let bytes = encode_tiff(&page).unwrap();
3722        let d = decode_tiff(&bytes).unwrap();
3723        assert_eq!((d.width, d.height), (8, 8));
3724        assert_eq!(d.pixel_format, TiffPixelFormat::Rgb24);
3725        let want = decode_cmyk_4sample(&pixels, 8, 8);
3726        assert_eq!(d.frame.planes[0].data, want);
3727    }
3728
3729    #[test]
3730    fn encode_cmyk32_compressors_match_uncompressed() {
3731        // PackBits / LZW / Deflate must all produce decoder output
3732        // identical to the uncompressed encode — they are lossless
3733        // byte-aligned compressors, photometric-agnostic.
3734        let pixels = cmyk_pattern_4sample(16, 8);
3735        let baseline = {
3736            let page = EncodePage {
3737                width: 16,
3738                height: 8,
3739                kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3740                compression: TiffCompression::None,
3741                predictor: false,
3742                planar: false,
3743                tiling: None,
3744                bigtiff: false,
3745            };
3746            decode_tiff(&encode_tiff(&page).unwrap())
3747                .unwrap()
3748                .frame
3749                .planes[0]
3750                .data
3751                .clone()
3752        };
3753        for c in [
3754            TiffCompression::PackBits,
3755            TiffCompression::Lzw,
3756            TiffCompression::Deflate,
3757        ] {
3758            let page = EncodePage {
3759                width: 16,
3760                height: 8,
3761                kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3762                compression: c,
3763                predictor: false,
3764                planar: false,
3765                tiling: None,
3766                bigtiff: false,
3767            };
3768            let d = decode_tiff(&encode_tiff(&page).unwrap()).unwrap();
3769            assert_eq!(d.frame.planes[0].data, baseline, "compressor {:?}", c);
3770        }
3771    }
3772
3773    #[test]
3774    fn encode_cmyk32_predictor_composes() {
3775        // Predictor=2 must round-trip on chunky 4-sample CMYK — the
3776        // decoder undoes the per-component differencing with
3777        // SamplesPerPixel = 4 (analogous to the Rgb24 / CieLab8 paths
3778        // that already exercise SPP=3).
3779        let pixels = cmyk_pattern_4sample(20, 12);
3780        let no_pred = {
3781            let page = EncodePage {
3782                width: 20,
3783                height: 12,
3784                kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3785                compression: TiffCompression::Lzw,
3786                predictor: false,
3787                planar: false,
3788                tiling: None,
3789                bigtiff: false,
3790            };
3791            decode_tiff(&encode_tiff(&page).unwrap())
3792                .unwrap()
3793                .frame
3794                .planes[0]
3795                .data
3796                .clone()
3797        };
3798        let with_pred = {
3799            let page = EncodePage {
3800                width: 20,
3801                height: 12,
3802                kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3803                compression: TiffCompression::Lzw,
3804                predictor: true,
3805                planar: false,
3806                tiling: None,
3807                bigtiff: false,
3808            };
3809            decode_tiff(&encode_tiff(&page).unwrap())
3810                .unwrap()
3811                .frame
3812                .planes[0]
3813                .data
3814                .clone()
3815        };
3816        assert_eq!(no_pred, with_pred);
3817    }
3818
3819    #[test]
3820    fn encode_cmyk32_planar_composes() {
3821        // PlanarConfiguration = 2 splits C / M / Y / K into four
3822        // single-component planes (§"PlanarConfiguration"). The
3823        // re-interleaved decode must match the chunky path.
3824        let pixels = cmyk_pattern_4sample(16, 8);
3825        let chunky = {
3826            let page = EncodePage {
3827                width: 16,
3828                height: 8,
3829                kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3830                compression: TiffCompression::Deflate,
3831                predictor: false,
3832                planar: false,
3833                tiling: None,
3834                bigtiff: false,
3835            };
3836            decode_tiff(&encode_tiff(&page).unwrap())
3837                .unwrap()
3838                .frame
3839                .planes[0]
3840                .data
3841                .clone()
3842        };
3843        let planar = {
3844            let page = EncodePage {
3845                width: 16,
3846                height: 8,
3847                kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3848                compression: TiffCompression::Deflate,
3849                predictor: false,
3850                planar: true,
3851                tiling: None,
3852                bigtiff: false,
3853            };
3854            decode_tiff(&encode_tiff(&page).unwrap())
3855                .unwrap()
3856                .frame
3857                .planes[0]
3858                .data
3859                .clone()
3860        };
3861        assert_eq!(chunky, planar);
3862    }
3863
3864    #[test]
3865    fn encode_cmyk32_tiled_composes() {
3866        // Tiled §15 chunky write — the strip-vs-tile decode must
3867        // collapse to the same Rgb24, mirroring the Rgb24 / CieLab8
3868        // tiled-roundtrip tests.
3869        let pixels = cmyk_pattern_4sample(32, 32);
3870        let strip = {
3871            let page = EncodePage {
3872                width: 32,
3873                height: 32,
3874                kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3875                compression: TiffCompression::Lzw,
3876                predictor: false,
3877                planar: false,
3878                tiling: None,
3879                bigtiff: false,
3880            };
3881            decode_tiff(&encode_tiff(&page).unwrap())
3882                .unwrap()
3883                .frame
3884                .planes[0]
3885                .data
3886                .clone()
3887        };
3888        let tiled = {
3889            let page = EncodePage {
3890                width: 32,
3891                height: 32,
3892                kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3893                compression: TiffCompression::Lzw,
3894                predictor: false,
3895                planar: false,
3896                tiling: Some((16, 16)),
3897                bigtiff: false,
3898            };
3899            decode_tiff(&encode_tiff(&page).unwrap())
3900                .unwrap()
3901                .frame
3902                .planes[0]
3903                .data
3904                .clone()
3905        };
3906        assert_eq!(strip, tiled);
3907    }
3908
3909    #[test]
3910    fn encode_cmyk32_bigtiff_composes() {
3911        // BigTIFF: BitsPerSample[4] (8 bytes) now sits inline in the
3912        // widened 8-byte value/offset slot. Self-roundtrip the same
3913        // as the classic path.
3914        let pixels = cmyk_pattern_4sample(8, 8);
3915        let page = EncodePage {
3916            width: 8,
3917            height: 8,
3918            kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3919            compression: TiffCompression::Deflate,
3920            predictor: false,
3921            planar: false,
3922            tiling: None,
3923            bigtiff: true,
3924        };
3925        let bytes = encode_tiff(&page).unwrap();
3926        assert_eq!(&bytes[..2], b"II");
3927        assert_eq!(u16::from_le_bytes([bytes[2], bytes[3]]), 43);
3928        let d = decode_tiff(&bytes).unwrap();
3929        let want = decode_cmyk_4sample(&pixels, 8, 8);
3930        assert_eq!(d.frame.planes[0].data, want);
3931    }
3932
3933    #[test]
3934    fn encode_cmyk32_rejects_ccitt() {
3935        // CCITT is bilevel-only per §10 / §11; Cmyk32 is 4-sample
3936        // 8-bit, so the bilevel-input gate must reject.
3937        let pixels = cmyk_pattern_4sample(8, 8);
3938        let page = EncodePage {
3939            width: 8,
3940            height: 8,
3941            kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3942            compression: TiffCompression::CcittRle,
3943            predictor: false,
3944            planar: false,
3945            tiling: None,
3946            bigtiff: false,
3947        };
3948        let err = encode_tiff(&page).unwrap_err();
3949        assert!(format!("{err}").contains("CCITT"));
3950    }
3951
3952    #[test]
3953    fn encode_cmyk32_wrong_buffer_size_rejected() {
3954        // 8x8 wants 256 bytes (4 * 64); pass 100 to exercise the size
3955        // validator.
3956        let bad = vec![0u8; 100];
3957        let page = EncodePage {
3958            width: 8,
3959            height: 8,
3960            kind: EncodePixelFormat::Cmyk32 { pixels: &bad },
3961            compression: TiffCompression::None,
3962            predictor: false,
3963            planar: false,
3964            tiling: None,
3965            bigtiff: false,
3966        };
3967        let err = encode_tiff(&page).unwrap_err();
3968        assert!(format!("{err}").contains("Cmyk32"));
3969    }
3970
3971    #[test]
3972    fn encode_cmyk32_writes_photometric_inkset_and_numberofinks() {
3973        // Decode the encoder's output through a byte-level IFD walker
3974        // (independent of our decoder) and confirm
3975        // PhotometricInterpretation = 5 lands in tag 262, InkSet = 1
3976        // in tag 332, and NumberOfInks = 4 in tag 334.
3977        let pixels = cmyk_pattern_4sample(8, 8);
3978        let page = EncodePage {
3979            width: 8,
3980            height: 8,
3981            kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
3982            compression: TiffCompression::None,
3983            predictor: false,
3984            planar: false,
3985            tiling: None,
3986            bigtiff: false,
3987        };
3988        let bytes = encode_tiff(&page).unwrap();
3989        let ifd_off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
3990        let count = u16::from_le_bytes([bytes[ifd_off], bytes[ifd_off + 1]]) as usize;
3991        let mut photo = None;
3992        let mut ink_set = None;
3993        let mut num_inks = None;
3994        for k in 0..count {
3995            let entry_off = ifd_off + 2 + k * 12;
3996            let tag = u16::from_le_bytes([bytes[entry_off], bytes[entry_off + 1]]);
3997            let val = u16::from_le_bytes([bytes[entry_off + 8], bytes[entry_off + 9]]);
3998            match tag {
3999                262 => photo = Some(val),
4000                332 => ink_set = Some(val),
4001                334 => num_inks = Some(val),
4002                _ => {}
4003            }
4004        }
4005        assert_eq!(photo, Some(5), "expected PhotometricInterpretation = 5");
4006        assert_eq!(ink_set, Some(1), "expected InkSet = 1 (CMYK)");
4007        assert_eq!(num_inks, Some(4), "expected NumberOfInks = 4");
4008    }
4009
4010    #[test]
4011    fn encode_cmyk32_pure_inks_collapse_to_expected_rgb() {
4012        // §16 fixes the additive-RGB collapse as `R = (1-C)(1-K)`,
4013        // `G = (1-M)(1-K)`, `B = (1-Y)(1-K)` (all scaled to 8-bit),
4014        // implemented by `build_rgb24_from_cmyk` in the decoder.
4015        // Check the four canonical "pure ink" pixels — pure C, pure
4016        // M, pure Y, pure K — land at the spec-mandated RGB triples
4017        // through the full encode -> decode path. This pins the
4018        // §16-required `0 = no ink` orientation: writing `(255, 0, 0,
4019        // 0)` (full cyan, no other ink, no black) must decode to a
4020        // red-absent pixel `(0, 255, 255)`, not the opposite.
4021        let pixels = [
4022            255, 0, 0, 0, // pure cyan
4023            0, 255, 0, 0, // pure magenta
4024            0, 0, 255, 0, // pure yellow
4025            0, 0, 0, 255, // pure black
4026        ];
4027        let page = EncodePage {
4028            width: 4,
4029            height: 1,
4030            kind: EncodePixelFormat::Cmyk32 { pixels: &pixels },
4031            compression: TiffCompression::None,
4032            predictor: false,
4033            planar: false,
4034            tiling: None,
4035            bigtiff: false,
4036        };
4037        let bytes = encode_tiff(&page).unwrap();
4038        let d = decode_tiff(&bytes).unwrap();
4039        assert_eq!(d.pixel_format, TiffPixelFormat::Rgb24);
4040        let rgb = &d.frame.planes[0].data;
4041        // Pure cyan -> R = (1-1)(1-0) = 0, G = B = 255.
4042        assert_eq!(&rgb[0..3], &[0, 255, 255]);
4043        // Pure magenta -> R = 255, G = 0, B = 255.
4044        assert_eq!(&rgb[3..6], &[255, 0, 255]);
4045        // Pure yellow -> R = 255, G = 255, B = 0.
4046        assert_eq!(&rgb[6..9], &[255, 255, 0]);
4047        // Pure black -> all zero (the (1-K) factor multiplies out).
4048        assert_eq!(&rgb[9..12], &[0, 0, 0]);
4049    }
4050}