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}