Skip to main content

oxihuman_core/
image_tiff.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Baseline TIFF encoder/decoder — uncompressed RGB, little-endian and big-endian read,
5//! little-endian write.
6//!
7//! # Format
8//!
9//! * Header   : `b"II"` + `0x2A 0x00` (magic 42, LE) + IFD offset (u32 LE)
10//! * IFD      : u16 entry-count, 12 bytes per entry, trailing 4-byte next-IFD (zero)
11//! * Required tags: ImageWidth, ImageLength, BitsPerSample, Compression (=1),
12//!   PhotometricInterpretation (=2/RGB), StripOffsets, SamplesPerPixel (=3),
13//!   RowsPerStrip, StripByteCounts, XResolution (72/1), YResolution (72/1),
14//!   ResolutionUnit (=2/inch)
15//! * Pixels   : row-major top-to-bottom, RGB interleaved, uncompressed.
16
17#![allow(dead_code)]
18
19use super::image_codec::RawDecodeResult;
20
21// ── Tag constants ─────────────────────────────────────────────────────────────
22
23const TAG_IMAGE_WIDTH: u16 = 0x0100;
24const TAG_IMAGE_LENGTH: u16 = 0x0101;
25const TAG_BITS_PER_SAMPLE: u16 = 0x0102;
26const TAG_COMPRESSION: u16 = 0x0103;
27const TAG_PHOTOMETRIC: u16 = 0x0106;
28const TAG_STRIP_OFFSETS: u16 = 0x0111;
29const TAG_SAMPLES_PER_PIXEL: u16 = 0x0115;
30const TAG_ROWS_PER_STRIP: u16 = 0x0116;
31const TAG_STRIP_BYTE_COUNTS: u16 = 0x0117;
32const TAG_X_RESOLUTION: u16 = 0x011A;
33const TAG_Y_RESOLUTION: u16 = 0x011B;
34const TAG_RESOLUTION_UNIT: u16 = 0x0128;
35
36// ── TIFF field types ──────────────────────────────────────────────────────────
37
38const TYPE_SHORT: u16 = 3;
39const TYPE_LONG: u16 = 4;
40const TYPE_RATIONAL: u16 = 5;
41
42// ── Error ─────────────────────────────────────────────────────────────────────
43
44/// Errors that can occur during TIFF encode/decode.
45#[derive(Debug, thiserror::Error)]
46pub enum TiffError {
47    #[error("Invalid TIFF: {0}")]
48    Invalid(String),
49    #[error("Unsupported TIFF feature: {0}")]
50    Unsupported(String),
51    #[error("Truncated data")]
52    Truncated,
53}
54
55// ── Encoder ───────────────────────────────────────────────────────────────────
56
57/// Encode raw RGB24 pixels (row-major, top-to-bottom) as a little-endian baseline TIFF file.
58///
59/// `pixels` must be exactly `width * height * 3` bytes.
60pub fn tiff_encode_rgb(width: u32, height: u32, pixels: &[u8]) -> Result<Vec<u8>, TiffError> {
61    let expected = (width as usize) * (height as usize) * 3;
62    if pixels.len() != expected {
63        return Err(TiffError::Invalid(format!(
64            "pixel buffer length {} != expected {}",
65            pixels.len(),
66            expected
67        )));
68    }
69
70    // Layout plan (all offsets from start of file, LE):
71    //   0x00 : 8-byte TIFF header
72    //   0x08 : IFD  (2 + 12*12 + 4 = 150 bytes)
73    //   0x9E : extra data (BitsPerSample u16[3]=6 bytes, XRes rational=8 bytes, YRes rational=8 bytes)
74    //   0xB4 : pixel data
75
76    const IFD_ENTRY_COUNT: u16 = 12;
77    const IFD_OFFSET: u32 = 8;
78    const IFD_SIZE: u32 = 2 + 12 * (IFD_ENTRY_COUNT as u32) + 4; // 150
79    const EXTRA_OFFSET: u32 = IFD_OFFSET + IFD_SIZE; // 158  (0x9E)
80    const BPS_OFFSET: u32 = EXTRA_OFFSET; // 3 × u16 = 6 bytes
81    const XRES_OFFSET: u32 = EXTRA_OFFSET + 6; // 2 × u32 = 8 bytes
82    const YRES_OFFSET: u32 = XRES_OFFSET + 8; // 2 × u32 = 8 bytes
83    const PIXEL_OFFSET: u32 = YRES_OFFSET + 8; // pixel data starts here
84
85    let pixel_byte_count: u32 = width * height * 3;
86    let total_size = (PIXEL_OFFSET as usize) + (pixel_byte_count as usize);
87
88    let mut buf: Vec<u8> = Vec::with_capacity(total_size);
89
90    // ── TIFF header (8 bytes) ─────────────────────────────────────────────────
91    buf.extend_from_slice(b"II"); // little-endian marker
92    buf.extend_from_slice(&42u16.to_le_bytes()); // magic
93    buf.extend_from_slice(&IFD_OFFSET.to_le_bytes()); // IFD offset = 8
94
95    // ── IFD ──────────────────────────────────────────────────────────────────
96    buf.extend_from_slice(&IFD_ENTRY_COUNT.to_le_bytes());
97
98    // Helper: write a 12-byte IFD entry
99    let write_entry = |out: &mut Vec<u8>, tag: u16, typ: u16, count: u32, val: u32| {
100        out.extend_from_slice(&tag.to_le_bytes());
101        out.extend_from_slice(&typ.to_le_bytes());
102        out.extend_from_slice(&count.to_le_bytes());
103        out.extend_from_slice(&val.to_le_bytes());
104    };
105
106    // Tags must be in ascending tag-number order (TIFF spec §2).
107    write_entry(&mut buf, TAG_IMAGE_WIDTH, TYPE_LONG, 1, width);
108    write_entry(&mut buf, TAG_IMAGE_LENGTH, TYPE_LONG, 1, height);
109    write_entry(&mut buf, TAG_BITS_PER_SAMPLE, TYPE_SHORT, 3, BPS_OFFSET); // offset to [8,8,8]
110    write_entry(&mut buf, TAG_COMPRESSION, TYPE_SHORT, 1, 1); // no compression
111    write_entry(&mut buf, TAG_PHOTOMETRIC, TYPE_SHORT, 1, 2); // RGB
112    write_entry(&mut buf, TAG_STRIP_OFFSETS, TYPE_LONG, 1, PIXEL_OFFSET);
113    write_entry(&mut buf, TAG_SAMPLES_PER_PIXEL, TYPE_SHORT, 1, 3);
114    write_entry(&mut buf, TAG_ROWS_PER_STRIP, TYPE_LONG, 1, height);
115    write_entry(
116        &mut buf,
117        TAG_STRIP_BYTE_COUNTS,
118        TYPE_LONG,
119        1,
120        pixel_byte_count,
121    );
122    write_entry(&mut buf, TAG_X_RESOLUTION, TYPE_RATIONAL, 1, XRES_OFFSET);
123    write_entry(&mut buf, TAG_Y_RESOLUTION, TYPE_RATIONAL, 1, YRES_OFFSET);
124    write_entry(&mut buf, TAG_RESOLUTION_UNIT, TYPE_SHORT, 1, 2); // inch
125
126    // Next IFD offset = 0 (no more IFDs)
127    buf.extend_from_slice(&0u32.to_le_bytes());
128
129    // ── Extra data ───────────────────────────────────────────────────────────
130    // BitsPerSample: [8, 8, 8] as u16 LE
131    buf.extend_from_slice(&8u16.to_le_bytes());
132    buf.extend_from_slice(&8u16.to_le_bytes());
133    buf.extend_from_slice(&8u16.to_le_bytes());
134
135    // XResolution: 72/1 as two u32 LE (numerator, denominator)
136    buf.extend_from_slice(&72u32.to_le_bytes());
137    buf.extend_from_slice(&1u32.to_le_bytes());
138
139    // YResolution: 72/1
140    buf.extend_from_slice(&72u32.to_le_bytes());
141    buf.extend_from_slice(&1u32.to_le_bytes());
142
143    // ── Pixel data ───────────────────────────────────────────────────────────
144    buf.extend_from_slice(pixels);
145
146    debug_assert_eq!(buf.len(), total_size);
147    Ok(buf)
148}
149
150// ── Decoder ───────────────────────────────────────────────────────────────────
151
152/// Decode a TIFF file (baseline, uncompressed, 8-bit RGB) into raw RGB24 pixels.
153///
154/// Supports both little-endian (`II`) and big-endian (`MM`) TIFF files.
155pub fn tiff_decode(bytes: &[u8]) -> Result<RawDecodeResult, TiffError> {
156    if bytes.len() < 8 {
157        return Err(TiffError::Truncated);
158    }
159
160    // Determine byte order from the first two bytes.
161    let little_endian = match &bytes[0..2] {
162        b"II" => true,
163        b"MM" => false,
164        _ => {
165            return Err(TiffError::Invalid(format!(
166                "unrecognised byte-order marker: {:02X} {:02X}",
167                bytes[0], bytes[1]
168            )))
169        }
170    };
171
172    let read_u16 = |off: usize| -> Result<u16, TiffError> {
173        bytes
174            .get(off..off + 2)
175            .and_then(|s| s.try_into().ok())
176            .map(|a: [u8; 2]| {
177                if little_endian {
178                    u16::from_le_bytes(a)
179                } else {
180                    u16::from_be_bytes(a)
181                }
182            })
183            .ok_or(TiffError::Truncated)
184    };
185
186    let read_u32 = |off: usize| -> Result<u32, TiffError> {
187        bytes
188            .get(off..off + 4)
189            .and_then(|s| s.try_into().ok())
190            .map(|a: [u8; 4]| {
191                if little_endian {
192                    u32::from_le_bytes(a)
193                } else {
194                    u32::from_be_bytes(a)
195                }
196            })
197            .ok_or(TiffError::Truncated)
198    };
199
200    // Validate magic number (42).
201    let magic = read_u16(2)?;
202    if magic != 42 {
203        return Err(TiffError::Invalid(format!("bad TIFF magic: {}", magic)));
204    }
205
206    let ifd_offset = read_u32(4)? as usize;
207
208    // ── Parse IFD entries ────────────────────────────────────────────────────
209    let entry_count = read_u16(ifd_offset)? as usize;
210    let entries_start = ifd_offset + 2;
211    if bytes.len() < entries_start + entry_count * 12 {
212        return Err(TiffError::Truncated);
213    }
214
215    // Per-entry reader: (tag, type, count, value_or_offset)
216    let read_entry = |n: usize| -> Result<(u16, u16, u32, u32), TiffError> {
217        let base = entries_start + n * 12;
218        let tag = read_u16(base)?;
219        let typ = read_u16(base + 2)?;
220        let count = read_u32(base + 4)?;
221        let val = read_u32(base + 8)?;
222        Ok((tag, typ, count, val))
223    };
224
225    // Collect values we need.
226    let mut width: Option<u32> = None;
227    let mut height: Option<u32> = None;
228    let mut compression: Option<u32> = None;
229    let mut samples_per_pixel: Option<u32> = None;
230    let mut strip_offsets: Option<u32> = None;
231    let mut strip_byte_counts: Option<u32> = None;
232    let mut bits_per_sample_info: Option<(u16, u32, u32)> = None; // (type, count, val_or_offset)
233
234    for i in 0..entry_count {
235        let (tag, typ, count, val) = read_entry(i)?;
236        match tag {
237            TAG_IMAGE_WIDTH => width = Some(val),
238            TAG_IMAGE_LENGTH => height = Some(val),
239            TAG_BITS_PER_SAMPLE => bits_per_sample_info = Some((typ, count, val)),
240            TAG_COMPRESSION => compression = Some(val),
241            TAG_STRIP_OFFSETS => strip_offsets = Some(val),
242            TAG_SAMPLES_PER_PIXEL => samples_per_pixel = Some(val),
243            TAG_STRIP_BYTE_COUNTS => strip_byte_counts = Some(val),
244            _ => {}
245        }
246    }
247
248    let width = width.ok_or_else(|| TiffError::Invalid("missing ImageWidth".into()))? as usize;
249    let height = height.ok_or_else(|| TiffError::Invalid("missing ImageLength".into()))? as usize;
250    let compression =
251        compression.ok_or_else(|| TiffError::Invalid("missing Compression".into()))?;
252    let spp =
253        samples_per_pixel.ok_or_else(|| TiffError::Invalid("missing SamplesPerPixel".into()))?;
254    let strip_offset =
255        strip_offsets.ok_or_else(|| TiffError::Invalid("missing StripOffsets".into()))? as usize;
256    let strip_bytes = strip_byte_counts
257        .ok_or_else(|| TiffError::Invalid("missing StripByteCounts".into()))?
258        as usize;
259
260    if compression != 1 {
261        return Err(TiffError::Unsupported(format!(
262            "compression={} (only 1/no-compression supported)",
263            compression
264        )));
265    }
266    if spp != 3 {
267        return Err(TiffError::Unsupported(format!(
268            "SamplesPerPixel={} (only 3/RGB supported)",
269            spp
270        )));
271    }
272
273    // Validate BitsPerSample: must be 8,8,8
274    if let Some((bps_type, bps_count, bps_val)) = bits_per_sample_info {
275        if bps_count != 3 {
276            return Err(TiffError::Unsupported(format!(
277                "BitsPerSample count={} (expected 3)",
278                bps_count
279            )));
280        }
281        // When count=3 the value field holds an *offset* to 3 u16 values.
282        // Validate each channel is 8-bit.
283        if bps_type == TYPE_SHORT {
284            let off = bps_val as usize;
285            if bytes.len() < off + 6 {
286                return Err(TiffError::Truncated);
287            }
288            let b0 = read_u16(off)?;
289            let b1 = read_u16(off + 2)?;
290            let b2 = read_u16(off + 4)?;
291            if b0 != 8 || b1 != 8 || b2 != 8 {
292                return Err(TiffError::Unsupported(format!(
293                    "BitsPerSample=[{},{},{}] (only 8,8,8 supported)",
294                    b0, b1, b2
295                )));
296            }
297        }
298    }
299
300    // Validate pixel buffer bounds.
301    let expected_bytes = width * height * 3;
302    if strip_bytes < expected_bytes {
303        return Err(TiffError::Invalid(format!(
304            "StripByteCounts={} < expected={}",
305            strip_bytes, expected_bytes
306        )));
307    }
308    if bytes.len() < strip_offset + expected_bytes {
309        return Err(TiffError::Truncated);
310    }
311
312    let pixels = bytes[strip_offset..strip_offset + expected_bytes].to_vec();
313
314    Ok(RawDecodeResult {
315        width,
316        height,
317        pixels,
318    })
319}
320
321// ── Tests ─────────────────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    /// Generate an 8×8 gradient test image (RGB24, row-major).
328    fn make_gradient(w: usize, h: usize) -> Vec<u8> {
329        let mut pixels = Vec::with_capacity(w * h * 3);
330        for y in 0..h {
331            for x in 0..w {
332                pixels.push(((x * 255) / w.max(1)) as u8); // R increases left→right
333                pixels.push(((y * 255) / h.max(1)) as u8); // G increases top→bottom
334                pixels.push(128u8); // B constant
335            }
336        }
337        pixels
338    }
339
340    #[test]
341    fn test_tiff_header_le() {
342        let pixels = vec![200u8; 4 * 4 * 3];
343        let encoded = tiff_encode_rgb(4, 4, &pixels).expect("encode must succeed");
344        assert!(
345            encoded.starts_with(b"II\x2A\x00"),
346            "must start with II (LE) + magic 42"
347        );
348    }
349
350    #[test]
351    fn test_tiff_roundtrip_exact() {
352        let pixels: Vec<u8> = (0u8..=255u8).cycle().take(4 * 4 * 3).collect();
353        let encoded = tiff_encode_rgb(4, 4, &pixels).expect("encode");
354        let decoded = tiff_decode(&encoded).expect("decode");
355        assert_eq!(decoded.width, 4);
356        assert_eq!(decoded.height, 4);
357        assert_eq!(
358            decoded.pixels, pixels,
359            "round-tripped pixels must be identical"
360        );
361    }
362
363    #[test]
364    fn test_tiff_dimensions() {
365        let pixels = vec![0u8; 10 * 6 * 3];
366        let encoded = tiff_encode_rgb(10, 6, &pixels).expect("encode");
367        let decoded = tiff_decode(&encoded).expect("decode");
368        assert_eq!(decoded.width, 10);
369        assert_eq!(decoded.height, 6);
370    }
371
372    #[test]
373    fn test_tiff_decode_invalid_returns_error() {
374        let result = tiff_decode(b"not a tiff file at all 12345678");
375        assert!(result.is_err(), "invalid data must return an error");
376    }
377
378    #[test]
379    fn test_tiff_roundtrip_gradient() {
380        let src = make_gradient(8, 8);
381        let encoded = tiff_encode_rgb(8, 8, &src).expect("encode");
382        let decoded = tiff_decode(&encoded).expect("decode");
383        assert_eq!(decoded.width, 8);
384        assert_eq!(decoded.height, 8);
385        assert_eq!(decoded.pixels, src, "gradient round-trip must be exact");
386    }
387
388    #[test]
389    fn test_tiff_wrong_pixel_buffer_size_returns_error() {
390        // Too few pixels for 4×4 image
391        let result = tiff_encode_rgb(4, 4, &[0u8; 10]);
392        assert!(result.is_err());
393    }
394
395    #[test]
396    fn test_tiff_truncated_returns_error() {
397        let pixels = vec![0u8; 4 * 4 * 3];
398        let encoded = tiff_encode_rgb(4, 4, &pixels).expect("encode");
399        // Truncate severely
400        let result = tiff_decode(&encoded[..6]);
401        assert!(result.is_err());
402    }
403}