Skip to main content

ad_plugins_rs/
file_tiff.rs

1use std::collections::BTreeMap;
2use std::ops::Range;
3use std::path::{Path, PathBuf};
4
5use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
6use ad_core_rs::color::NDColorMode;
7use ad_core_rs::error::{ADError, ADResult};
8use ad_core_rs::ndarray::{NDArray, NDDataType, NDDimension};
9use ad_core_rs::ndarray_pool::NDArrayPool;
10use ad_core_rs::plugin::file_base::{NDFileMode, NDFileWriter};
11use ad_core_rs::plugin::file_controller::FilePluginController;
12use ad_core_rs::plugin::runtime::{
13    NDPluginProcess, ParamChangeResult, PluginParamSnapshot, ProcessResult,
14};
15use parking_lot::Mutex;
16
17// TIFF tag numbers. 256-339 are the baseline tags C sets through TIFFSetField
18// (NDFileTIFF.cpp:231-238, :244-267); 65000-65003 and 65010+ are the custom
19// EPICS tags (:38-42).
20const TAG_IMAGE_WIDTH: u16 = 256;
21const TAG_IMAGE_LENGTH: u16 = 257;
22const TAG_BITS_PER_SAMPLE: u16 = 258;
23const TAG_COMPRESSION: u16 = 259;
24const TAG_PHOTOMETRIC: u16 = 262;
25const TAG_IMAGE_DESCRIPTION: u16 = 270;
26const TAG_MAKE: u16 = 271;
27const TAG_MODEL: u16 = 272;
28const TAG_STRIP_OFFSETS: u16 = 273;
29const TAG_SAMPLES_PER_PIXEL: u16 = 277;
30const TAG_ROWS_PER_STRIP: u16 = 278;
31const TAG_STRIP_BYTE_COUNTS: u16 = 279;
32const TAG_PLANAR_CONFIG: u16 = 284;
33const TAG_SOFTWARE: u16 = 305;
34const TAG_SAMPLE_FORMAT: u16 = 339;
35const TIFFTAG_NDTIMESTAMP: u16 = 65000;
36const TIFFTAG_UNIQUEID: u16 = 65001;
37const TIFFTAG_EPICSTSSEC: u16 = 65002;
38const TIFFTAG_EPICSTSNSEC: u16 = 65003;
39const TIFFTAG_FIRST_ATTRIBUTE: u16 = 65010;
40
41// TIFF field types.
42const TYPE_ASCII: u16 = 2;
43const TYPE_SHORT: u16 = 3;
44const TYPE_LONG: u16 = 4;
45const TYPE_DOUBLE: u16 = 12;
46
47// libtiff constants, as passed by C.
48const COMPRESSION_NONE: u16 = 1;
49const PHOTOMETRIC_MINISBLACK: u16 = 1;
50const PHOTOMETRIC_RGB: u16 = 2;
51const PLANARCONFIG_CONTIG: u16 = 1;
52const PLANARCONFIG_SEPARATE: u16 = 2;
53const SAMPLEFORMAT_UINT: u16 = 1;
54const SAMPLEFORMAT_INT: u16 = 2;
55const SAMPLEFORMAT_IEEEFP: u16 = 3;
56
57/// The image geometry C derives in `NDFileTIFF::openFile` (NDFileTIFF.cpp:
58/// 180-224) together with the strip layout its `writeFile` (:383-406) writes.
59///
60/// The two are one decision: RGB2 and RGB3 are stored as three *separate* colour
61/// planes (`PLANARCONFIG_SEPARATE`), and RGB2 additionally uses one row per strip
62/// because its source rows interleave the planes. Deriving both from a single
63/// struct is what keeps the tag values and the strip bytes from disagreeing.
64struct TiffLayout {
65    width: usize,
66    height: usize,
67    samples_per_pixel: u16,
68    photometric: u16,
69    planar_config: u16,
70    rows_per_strip: usize,
71    color_mode: NDColorMode,
72}
73
74/// One IFD entry, ready to be laid out.
75struct IfdEntry {
76    tag: u16,
77    field_type: u16,
78    count: u32,
79    /// Encoded value bytes; inlined into the entry when ≤ 4 bytes, otherwise
80    /// written to the value area and referenced by offset.
81    data: Vec<u8>,
82}
83
84impl IfdEntry {
85    fn ascii(tag: u16, value: &str) -> Self {
86        let mut data = value.as_bytes().to_vec();
87        data.push(0); // TIFF ASCII values are NUL-terminated, and the NUL counts.
88        Self {
89            tag,
90            field_type: TYPE_ASCII,
91            count: data.len() as u32,
92            data,
93        }
94    }
95
96    fn short(tag: u16, values: &[u16]) -> Self {
97        Self {
98            tag,
99            field_type: TYPE_SHORT,
100            count: values.len() as u32,
101            data: values.iter().flat_map(|v| v.to_le_bytes()).collect(),
102        }
103    }
104
105    fn long(tag: u16, values: &[u32]) -> Self {
106        Self {
107            tag,
108            field_type: TYPE_LONG,
109            count: values.len() as u32,
110            data: values.iter().flat_map(|v| v.to_le_bytes()).collect(),
111        }
112    }
113
114    /// libtiff writes the size tags (ImageWidth, ImageLength, RowsPerStrip) as
115    /// SHORT when the value fits and LONG otherwise (`TIFFWriteDirectoryTag
116    /// ShortLong`), so a C-written file uses whichever the value calls for.
117    fn short_or_long(tag: u16, value: usize) -> ADResult<Self> {
118        let value = u32::try_from(value)
119            .map_err(|_| ADError::InvalidDimensions("TIFF dimension exceeds 2^32".into()))?;
120        Ok(if value <= u32::from(u16::MAX) {
121            Self::short(tag, &[value as u16])
122        } else {
123            Self::long(tag, &[value])
124        })
125    }
126
127    fn double(tag: u16, value: f64) -> Self {
128        Self {
129            tag,
130            field_type: TYPE_DOUBLE,
131            count: 1,
132            data: value.to_le_bytes().to_vec(),
133        }
134    }
135}
136
137/// Format an NDAttribute value as the C++ `epicsSnprintf` "name:value" tag
138/// string (NDFileTIFF.cpp:303-327). Numeric values keep their type, not a
139/// generic stringification: signed `%lld`, unsigned `%llu`, float `%f`.
140fn attribute_tag_string(attr: &NDAttribute) -> String {
141    let value = match &attr.value {
142        NDAttrValue::Int8(v) => format!("{}", v),
143        NDAttrValue::Int16(v) => format!("{}", v),
144        NDAttrValue::Int32(v) => format!("{}", v),
145        NDAttrValue::Int64(v) => format!("{}", v),
146        NDAttrValue::UInt8(v) => format!("{}", v),
147        NDAttrValue::UInt16(v) => format!("{}", v),
148        NDAttrValue::UInt32(v) => format!("{}", v),
149        NDAttrValue::UInt64(v) => format!("{}", v),
150        // C++ uses "%f" which is 6 fractional digits.
151        NDAttrValue::Float32(v) => format!("{:.6}", v),
152        NDAttrValue::Float64(v) => format!("{:.6}", v),
153        NDAttrValue::String(s) => s.clone(),
154        NDAttrValue::Undefined => String::new(),
155    };
156    format!("{}:{}", attr.name, value)
157}
158
159/// TIFF file writer using the `tiff` crate for proper encoding/decoding.
160pub struct TiffWriter {
161    current_path: Option<PathBuf>,
162}
163
164impl TiffWriter {
165    pub fn new() -> Self {
166        Self { current_path: None }
167    }
168
169    /// C's `openFile` structure switch (NDFileTIFF.cpp:180-224). Note the two
170    /// RGB planar layouts: RGB2 gets `rowsPerStrip = 1` because its rows
171    /// interleave the three planes, RGB3 keeps `rowsPerStrip = sizeY` because
172    /// each plane is already contiguous. Both are `PLANARCONFIG_SEPARATE`.
173    fn layout(array: &NDArray) -> ADResult<TiffLayout> {
174        // The ColorMode *attribute* is the only source of truth, defaulting to
175        // Mono when it is absent — C `int colorMode = NDColorModeMono;` (:81)
176        // overwritten only by the attribute (:135-136). `info().color_mode` is
177        // that rule's single owner. Inferring a colour layout from the dimensions
178        // instead made a 3-D array with no ColorMode attribute look like RGB1 and
179        // write a file, where C matches none of its 3-D branches (each requires
180        // the attribute, :196/:204/:212), falls through to the else, and returns
181        // asynError (:220-224).
182        let color_mode = array.info().color_mode;
183        let mono = |width: usize, height: usize| TiffLayout {
184            width,
185            height,
186            samples_per_pixel: 1,
187            photometric: PHOTOMETRIC_MINISBLACK,
188            planar_config: PLANARCONFIG_CONTIG,
189            rows_per_strip: height,
190            color_mode: NDColorMode::Mono,
191        };
192
193        Ok(match array.dims.as_slice() {
194            [x] => mono(x.size, 1),
195            [x, y] => mono(x.size, y.size),
196            [c, x, y] if c.size == 3 && color_mode == NDColorMode::RGB1 => TiffLayout {
197                width: x.size,
198                height: y.size,
199                samples_per_pixel: 3,
200                photometric: PHOTOMETRIC_RGB,
201                planar_config: PLANARCONFIG_CONTIG,
202                rows_per_strip: y.size,
203                color_mode: NDColorMode::RGB1,
204            },
205            [x, c, y] if c.size == 3 && color_mode == NDColorMode::RGB2 => TiffLayout {
206                width: x.size,
207                height: y.size,
208                samples_per_pixel: 3,
209                photometric: PHOTOMETRIC_RGB,
210                planar_config: PLANARCONFIG_SEPARATE,
211                rows_per_strip: 1,
212                color_mode: NDColorMode::RGB2,
213            },
214            [x, y, c] if c.size == 3 && color_mode == NDColorMode::RGB3 => TiffLayout {
215                width: x.size,
216                height: y.size,
217                samples_per_pixel: 3,
218                photometric: PHOTOMETRIC_RGB,
219                planar_config: PLANARCONFIG_SEPARATE,
220                rows_per_strip: y.size,
221                color_mode: NDColorMode::RGB3,
222            },
223            _ => {
224                return Err(ADError::InvalidDimensions(
225                    "unsupported array structure".into(),
226                ));
227            }
228        })
229    }
230
231    /// C `sampleFormat`/`bitsPerSample` (NDFileTIFF.cpp:139-179).
232    fn sample_format_and_bits(data_type: NDDataType) -> (u16, u16) {
233        match data_type {
234            NDDataType::Int8 => (SAMPLEFORMAT_INT, 8),
235            NDDataType::UInt8 => (SAMPLEFORMAT_UINT, 8),
236            NDDataType::Int16 => (SAMPLEFORMAT_INT, 16),
237            NDDataType::UInt16 => (SAMPLEFORMAT_UINT, 16),
238            NDDataType::Int32 => (SAMPLEFORMAT_INT, 32),
239            NDDataType::UInt32 => (SAMPLEFORMAT_UINT, 32),
240            NDDataType::Int64 => (SAMPLEFORMAT_INT, 64),
241            NDDataType::UInt64 => (SAMPLEFORMAT_UINT, 64),
242            NDDataType::Float32 => (SAMPLEFORMAT_IEEEFP, 32),
243            NDDataType::Float64 => (SAMPLEFORMAT_IEEEFP, 64),
244        }
245    }
246
247    /// The strips C's `writeFile` emits, as `(strip index, source byte range)`
248    /// **in the order C writes them** (NDFileTIFF.cpp:383-406).
249    ///
250    /// For RGB2 that order is not the strip order: C walks rows and writes red
251    /// row *s* as strip `s`, green as strip `sizeY + s`, blue as strip
252    /// `2*sizeY + s`, so the file's byte stream stays row-interleaved while the
253    /// StripOffsets table de-interleaves it into three planes. A reader walking
254    /// strips in index order therefore sees plane R, plane G, plane B — which is
255    /// exactly how C's own `readFile` recovers the image (:497-505, as RGB3).
256    fn strip_writes(layout: &TiffLayout, bytes_per_sample: usize) -> Vec<(usize, Range<usize>)> {
257        let TiffLayout { width, height, .. } = *layout;
258        match layout.color_mode {
259            NDColorMode::RGB2 => {
260                let strip = width * bytes_per_sample; // one row of one plane
261                let mut writes = Vec::with_capacity(3 * height);
262                for row in 0..height {
263                    let red = 3 * row * strip;
264                    writes.push((row, red..red + strip));
265                    writes.push((height + row, red + strip..red + 2 * strip));
266                    writes.push((2 * height + row, red + 2 * strip..red + 3 * strip));
267                }
268                writes
269            }
270            NDColorMode::RGB3 => {
271                let plane = width * height * bytes_per_sample;
272                (0..3).map(|p| (p, p * plane..(p + 1) * plane)).collect()
273            }
274            // Mono and RGB1 are chunky: a single strip holding the whole image.
275            _ => {
276                let total =
277                    width * height * usize::from(layout.samples_per_pixel) * bytes_per_sample;
278                vec![(0, 0..total)]
279            }
280        }
281    }
282
283    /// Serialise a classic little-endian TIFF: header, strip data, then the IFD
284    /// and its value area.
285    ///
286    /// Written by hand rather than through the `tiff` crate because that crate
287    /// cannot express `PLANARCONFIG_SEPARATE` (its ImageEncoder assumes chunky
288    /// strips of `width * samples` each) and unconditionally emits XResolution /
289    /// YResolution / ResolutionUnit, which C never sets. The tag set below is
290    /// exactly C's `TIFFSetField` calls plus the baseline structural tags libtiff
291    /// itself writes (Compression, StripOffsets, StripByteCounts).
292    fn encode(array: &NDArray, layout: &TiffLayout) -> ADResult<Vec<u8>> {
293        let raw = array.data.as_u8_slice();
294        let (sample_format, bits_per_sample) = Self::sample_format_and_bits(array.data.data_type());
295        let bytes_per_sample = usize::from(bits_per_sample) / 8;
296
297        let writes = Self::strip_writes(layout, bytes_per_sample);
298        let expected: usize = writes.iter().map(|(_, r)| r.len()).sum();
299        if raw.len() < expected {
300            return Err(ADError::InvalidDimensions(format!(
301                "TIFF: array holds {} bytes, the {:?} layout needs {}",
302                raw.len(),
303                layout.color_mode,
304                expected
305            )));
306        }
307
308        let mut out: Vec<u8> = Vec::with_capacity(expected + 1024);
309        out.extend_from_slice(b"II"); // little-endian
310        out.extend_from_slice(&42u16.to_le_bytes());
311        out.extend_from_slice(&0u32.to_le_bytes()); // IFD offset, patched below
312
313        let strip_count = writes.len();
314        let mut strip_offsets = vec![0u32; strip_count];
315        let mut strip_byte_counts = vec![0u32; strip_count];
316        for (index, range) in writes {
317            strip_offsets[index] = out.len() as u32;
318            strip_byte_counts[index] = range.len() as u32;
319            out.extend_from_slice(&raw[range]);
320        }
321        if out.len() % 2 != 0 {
322            out.push(0); // IFDs must begin on a word boundary
323        }
324
325        let samples = usize::from(layout.samples_per_pixel);
326        let mut entries = vec![
327            IfdEntry::short_or_long(TAG_IMAGE_WIDTH, layout.width)?,
328            IfdEntry::short_or_long(TAG_IMAGE_LENGTH, layout.height)?,
329            IfdEntry::short(TAG_BITS_PER_SAMPLE, &vec![bits_per_sample; samples]),
330            IfdEntry::short(TAG_COMPRESSION, &[COMPRESSION_NONE]),
331            IfdEntry::short(TAG_PHOTOMETRIC, &[layout.photometric]),
332            IfdEntry::long(TAG_STRIP_OFFSETS, &strip_offsets),
333            IfdEntry::short(TAG_SAMPLES_PER_PIXEL, &[layout.samples_per_pixel]),
334            IfdEntry::short_or_long(TAG_ROWS_PER_STRIP, layout.rows_per_strip)?,
335            IfdEntry::long(TAG_STRIP_BYTE_COUNTS, &strip_byte_counts),
336            IfdEntry::short(TAG_PLANAR_CONFIG, &[layout.planar_config]),
337            IfdEntry::short(TAG_SAMPLE_FORMAT, &vec![sample_format; samples]),
338        ];
339
340        // Standard tags derived from well-known attributes (NDFileTIFF.cpp:244-267).
341        let model = array
342            .attributes
343            .get("Model")
344            .map(|a| a.value.as_string())
345            .unwrap_or_else(|| "Unknown".to_string());
346        let make = array
347            .attributes
348            .get("Manufacturer")
349            .map(|a| a.value.as_string())
350            .unwrap_or_else(|| "Unknown".to_string());
351        entries.push(IfdEntry::ascii(TAG_MODEL, &model));
352        entries.push(IfdEntry::ascii(TAG_MAKE, &make));
353        entries.push(IfdEntry::ascii(TAG_SOFTWARE, "EPICS areaDetector"));
354        if let Some(desc) = array.attributes.get("TIFFImageDescription") {
355            entries.push(IfdEntry::ascii(
356                TAG_IMAGE_DESCRIPTION,
357                &desc.value.as_string(),
358            ));
359        }
360
361        // EPICS metadata tags 65000-65003 — types per C's tiffFieldInfo (:47-51).
362        entries.push(IfdEntry::double(TIFFTAG_NDTIMESTAMP, array.time_stamp));
363        entries.push(IfdEntry::long(TIFFTAG_UNIQUEID, &[array.unique_id as u32]));
364        entries.push(IfdEntry::long(TIFFTAG_EPICSTSSEC, &[array.timestamp.sec]));
365        entries.push(IfdEntry::long(TIFFTAG_EPICSTSNSEC, &[array.timestamp.nsec]));
366
367        // NDArray attributes as ASCII tags from 65010 (:277-355).
368        for (i, attr) in array.attributes.iter().enumerate() {
369            let Some(tag) = TIFFTAG_FIRST_ATTRIBUTE.checked_add(i as u16) else {
370                break; // C stops at TIFFTAG_LAST_ATTRIBUTE (65535).
371            };
372            entries.push(IfdEntry::ascii(tag, &attribute_tag_string(attr)));
373        }
374
375        entries.sort_by_key(|e| e.tag); // TIFF requires ascending tag order
376
377        let ifd_offset = out.len();
378        let ifd_size = 2 + 12 * entries.len() + 4;
379        let mut values_offset = ifd_offset + ifd_size;
380        let mut ifd: Vec<u8> = Vec::with_capacity(ifd_size);
381        let mut values: Vec<u8> = Vec::new();
382
383        ifd.extend_from_slice(&(entries.len() as u16).to_le_bytes());
384        for entry in &entries {
385            ifd.extend_from_slice(&entry.tag.to_le_bytes());
386            ifd.extend_from_slice(&entry.field_type.to_le_bytes());
387            ifd.extend_from_slice(&entry.count.to_le_bytes());
388            if entry.data.len() <= 4 {
389                let mut inline = entry.data.clone();
390                inline.resize(4, 0);
391                ifd.extend_from_slice(&inline);
392            } else {
393                ifd.extend_from_slice(&(values_offset as u32).to_le_bytes());
394                values.extend_from_slice(&entry.data);
395                if values.len() % 2 != 0 {
396                    values.push(0);
397                }
398                values_offset = ifd_offset + ifd_size + values.len();
399            }
400        }
401        ifd.extend_from_slice(&0u32.to_le_bytes()); // no next IFD
402
403        out.extend_from_slice(&ifd);
404        out.extend_from_slice(&values);
405        out[4..8].copy_from_slice(&(ifd_offset as u32).to_le_bytes());
406        Ok(out)
407    }
408
409    fn attach_color_mode(array: &mut NDArray, color_mode: NDColorMode) {
410        array.attributes.add(NDAttribute::new_static(
411            "ColorMode",
412            "Color mode",
413            NDAttrSource::Driver,
414            NDAttrValue::Int32(color_mode as i32),
415        ));
416    }
417}
418
419impl NDFileWriter for TiffWriter {
420    fn open_file(&mut self, path: &Path, _mode: NDFileMode, _array: &NDArray) -> ADResult<()> {
421        self.current_path = Some(path.to_path_buf());
422        Ok(())
423    }
424
425    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
426        let path = self
427            .current_path
428            .as_ref()
429            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
430        let layout = Self::layout(array)?;
431        let bytes = Self::encode(array, &layout)?;
432        std::fs::write(path, bytes)?;
433        Ok(())
434    }
435
436    fn read_file(&mut self) -> ADResult<NDArray> {
437        let path = self
438            .current_path
439            .as_ref()
440            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
441        let bytes = std::fs::read(path)?;
442        decode(&bytes)
443    }
444
445    fn close_file(&mut self) -> ADResult<()> {
446        self.current_path = None;
447        Ok(())
448    }
449
450    fn supports_multiple_arrays(&self) -> bool {
451        false
452    }
453}
454
455/// Read one classic TIFF, mirroring C `NDFileTIFF::readFile`
456/// (NDFileTIFF.cpp:426-560).
457///
458/// C classifies the image from (photometric, planarConfig, samplesPerPixel):
459/// MINISBLACK/CONTIG/1 → Mono `[x, y]`, RGB/CONTIG/3 → RGB1 `[3, x, y]`, and
460/// RGB/SEPARATE/3 → RGB3 `[x, y, 3]` — a planar file always comes back as RGB3,
461/// even when written from an RGB2 array, because the strips are read in index
462/// order and that order is plane R, plane G, plane B. Anything else is an error.
463/// Strips are concatenated in index order into the array buffer (:513-524).
464fn decode(bytes: &[u8]) -> ADResult<NDArray> {
465    let err = |m: &str| ADError::UnsupportedConversion(format!("TIFF decode error: {m}"));
466
467    let read_u16 = |off: usize| -> ADResult<u16> {
468        bytes
469            .get(off..off + 2)
470            .map(|b| u16::from_le_bytes([b[0], b[1]]))
471            .ok_or_else(|| err("truncated"))
472    };
473    let read_u32 = |off: usize| -> ADResult<u32> {
474        bytes
475            .get(off..off + 4)
476            .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
477            .ok_or_else(|| err("truncated"))
478    };
479
480    if bytes.len() < 8 || &bytes[0..2] != b"II" || read_u16(2)? != 42 {
481        return Err(err("not a little-endian classic TIFF"));
482    }
483
484    // The reader only needs the integer tags; ASCII/DOUBLE entries are skipped.
485    let ifd = read_u32(4)? as usize;
486    let entry_count = read_u16(ifd)? as usize;
487    let mut tags: BTreeMap<u16, Vec<u64>> = BTreeMap::new();
488    for i in 0..entry_count {
489        let entry = ifd + 2 + 12 * i;
490        let tag = read_u16(entry)?;
491        let size = match read_u16(entry + 2)? {
492            TYPE_SHORT => 2usize,
493            TYPE_LONG => 4,
494            _ => continue,
495        };
496        let n = read_u32(entry + 4)? as usize;
497        let base = if n * size <= 4 {
498            entry + 8
499        } else {
500            read_u32(entry + 8)? as usize
501        };
502        let mut values = Vec::with_capacity(n);
503        for k in 0..n {
504            values.push(match size {
505                2 => u64::from(read_u16(base + 2 * k)?),
506                _ => u64::from(read_u32(base + 4 * k)?),
507            });
508        }
509        tags.insert(tag, values);
510    }
511
512    let scalar = |tag: u16| -> Option<u64> { tags.get(&tag).and_then(|v| v.first().copied()) };
513    let width = scalar(TAG_IMAGE_WIDTH).ok_or_else(|| err("no ImageWidth"))? as usize;
514    let height = scalar(TAG_IMAGE_LENGTH).ok_or_else(|| err("no ImageLength"))? as usize;
515    let bits = scalar(TAG_BITS_PER_SAMPLE).ok_or_else(|| err("no BitsPerSample"))?;
516    let samples_per_pixel = scalar(TAG_SAMPLES_PER_PIXEL).unwrap_or(1);
517    let photometric = scalar(TAG_PHOTOMETRIC).ok_or_else(|| err("no PhotometricInterpretation"))?;
518    let planar_config = scalar(TAG_PLANAR_CONFIG).unwrap_or(u64::from(PLANARCONFIG_CONTIG));
519    // C: "Sample format is not defined! Default UINT is used." (:457-463)
520    let sample_format = match scalar(TAG_SAMPLE_FORMAT) {
521        Some(0) | None => u64::from(SAMPLEFORMAT_UINT),
522        Some(v) => v,
523    };
524
525    let data_type = match (bits, sample_format as u16) {
526        (8, SAMPLEFORMAT_INT) => NDDataType::Int8,
527        (8, SAMPLEFORMAT_UINT) => NDDataType::UInt8,
528        (16, SAMPLEFORMAT_INT) => NDDataType::Int16,
529        (16, SAMPLEFORMAT_UINT) => NDDataType::UInt16,
530        (32, SAMPLEFORMAT_INT) => NDDataType::Int32,
531        (32, SAMPLEFORMAT_UINT) => NDDataType::UInt32,
532        (64, SAMPLEFORMAT_INT) => NDDataType::Int64,
533        (64, SAMPLEFORMAT_UINT) => NDDataType::UInt64,
534        (32, SAMPLEFORMAT_IEEEFP) => NDDataType::Float32,
535        (64, SAMPLEFORMAT_IEEEFP) => NDDataType::Float64,
536        _ => {
537            return Err(err(&format!(
538                "unsupported bitsPerSample={bits} sampleFormat={sample_format}"
539            )));
540        }
541    };
542
543    let (dims, color_mode) = match (photometric as u16, planar_config as u16, samples_per_pixel) {
544        (PHOTOMETRIC_MINISBLACK, PLANARCONFIG_CONTIG, 1) => (
545            vec![NDDimension::new(width), NDDimension::new(height)],
546            NDColorMode::Mono,
547        ),
548        (PHOTOMETRIC_RGB, PLANARCONFIG_CONTIG, 3) => (
549            vec![
550                NDDimension::new(3),
551                NDDimension::new(width),
552                NDDimension::new(height),
553            ],
554            NDColorMode::RGB1,
555        ),
556        (PHOTOMETRIC_RGB, PLANARCONFIG_SEPARATE, 3) => (
557            vec![
558                NDDimension::new(width),
559                NDDimension::new(height),
560                NDDimension::new(3),
561            ],
562            NDColorMode::RGB3,
563        ),
564        _ => {
565            return Err(err(&format!(
566                "unsupported photoMetric={photometric}, planarConfig={planar_config}, \
567                 samplesPerPixel={samples_per_pixel}"
568            )));
569        }
570    };
571
572    let offsets = tags
573        .get(&TAG_STRIP_OFFSETS)
574        .ok_or_else(|| err("no StripOffsets"))?;
575    let counts = tags
576        .get(&TAG_STRIP_BYTE_COUNTS)
577        .ok_or_else(|| err("no StripByteCounts"))?;
578    if offsets.len() != counts.len() {
579        return Err(err("StripOffsets/StripByteCounts length mismatch"));
580    }
581    let mut raw: Vec<u8> = Vec::new();
582    for (offset, byte_count) in offsets.iter().zip(counts) {
583        let (start, len) = (*offset as usize, *byte_count as usize);
584        let strip = bytes
585            .get(start..start + len)
586            .ok_or_else(|| err("strip extends past the end of the file"))?;
587        raw.extend_from_slice(strip);
588    }
589
590    let mut array = NDArray::new(dims, data_type);
591    array.data = crate::codec::buffer_from_bytes(&raw, data_type)
592        .ok_or_else(|| err("strip data is not a whole number of samples"))?;
593    TiffWriter::attach_color_mode(&mut array, color_mode);
594    Ok(array)
595}
596
597/// TIFF file processor wrapping `FilePluginController<TiffWriter>`.
598pub struct TiffFileProcessor {
599    pub ctrl: Mutex<FilePluginController<TiffWriter>>,
600}
601
602impl TiffFileProcessor {
603    pub fn new() -> Self {
604        Self {
605            ctrl: Mutex::new(FilePluginController::new(TiffWriter::new())),
606        }
607    }
608}
609
610impl Default for TiffFileProcessor {
611    fn default() -> Self {
612        Self::new()
613    }
614}
615
616impl NDPluginProcess for TiffFileProcessor {
617    fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
618        self.ctrl.lock().process_array(array)
619    }
620
621    fn plugin_type(&self) -> &str {
622        "NDFileTIFF"
623    }
624
625    /// C `NDPluginFile.cpp:948` (base of every file writer) sets
626    /// `NDArrayCallbacks = 0`: file plugins write to disk, not downstream.
627    fn does_array_callbacks(&self) -> bool {
628        false
629    }
630
631    fn register_params(
632        &mut self,
633        base: &mut asyn_rs::port::PortDriverBase,
634    ) -> asyn_rs::error::AsynResult<()> {
635        self.ctrl.lock().register_params(base)
636    }
637
638    fn on_param_change(&self, reason: usize, params: &PluginParamSnapshot) -> ParamChangeResult {
639        self.ctrl.lock().on_param_change(reason, params)
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use ad_core_rs::ndarray::NDDataBuffer;
647    // The tests verify the bytes this module writes with an INDEPENDENT TIFF
648    // decoder (the `tiff` crate), not with our own reader.
649    use ad_core_rs::params::ndarray_driver::NDArrayDriverParams;
650    use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
651    use asyn_rs::port::{PortDriverBase, PortFlags};
652    use std::sync::atomic::{AtomicU32, Ordering};
653    use tiff::decoder::Decoder;
654    use tiff::tags::Tag;
655
656    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
657
658    fn temp_path(prefix: &str) -> PathBuf {
659        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
660        std::env::temp_dir().join(format!(
661            "adcore_test_{}_{}_{}.tif",
662            std::process::id(),
663            prefix,
664            n
665        ))
666    }
667
668    /// R8-75: a 3-D array whose ColorMode attribute is absent is an error in C.
669    ///
670    /// C defaults `colorMode` to Mono (NDFileTIFF.cpp:81) and overwrites it only
671    /// from the attribute (:135-136). Each of the three 3-D branches requires the
672    /// attribute to name the layout (:196, :204, :212), so a 3-D array without it
673    /// matches none of them, falls to the else, and returns asynError (:220-224).
674    /// The port inferred RGB1 from `dims[0] == 3` and wrote a file.
675    #[test]
676    fn test_r8_75_3d_without_colormode_attribute_is_an_error() {
677        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
678        use ad_core_rs::color::NDColorMode;
679
680        let rgb1_dims = || {
681            vec![
682                NDDimension::new(3),
683                NDDimension::new(4),
684                NDDimension::new(4),
685            ]
686        };
687
688        // No ColorMode attribute → C's colorMode stays Mono → asynError.
689        let arr = NDArray::new(rgb1_dims(), NDDataType::UInt8);
690        let path = temp_path("tiff_3d_no_colormode");
691        let mut writer = TiffWriter::new();
692        writer
693            .open_file(&path, NDFileMode::Single, &arr)
694            .expect("open");
695        let err = writer.write_file(&arr).unwrap_err();
696        assert!(
697            matches!(err, ADError::InvalidDimensions(_)),
698            "3-D without ColorMode must be rejected, got {err:?}"
699        );
700        // ADP-95(b): C rejects in `openFile` (NDFileTIFF.cpp:220-224), before any write, so no file
701        // exists. The port decides one call later but must leave the same
702        // absence — a caller branching on the error must not find a file.
703        assert!(
704            !path.exists(),
705            "a rejected array must leave no file: {}",
706            path.display()
707        );
708        std::fs::remove_file(&path).ok();
709
710        // Positive control: the same dims WITH ColorMode=RGB1 write normally, so
711        // the rejection is keyed on the attribute, not on the shape.
712        let mut arr = NDArray::new(rgb1_dims(), NDDataType::UInt8);
713        arr.attributes.add(NDAttribute::new_static(
714            "ColorMode",
715            "",
716            NDAttrSource::Driver,
717            NDAttrValue::Int32(NDColorMode::RGB1 as i32),
718        ));
719        let path = temp_path("tiff_3d_rgb1");
720        let mut writer = TiffWriter::new();
721        writer
722            .open_file(&path, NDFileMode::Single, &arr)
723            .expect("open");
724        writer
725            .write_file(&arr)
726            .expect("3-D WITH ColorMode=RGB1 must still write");
727        writer.close_file().ok();
728        assert!(path.exists());
729        std::fs::remove_file(&path).ok();
730    }
731
732    #[test]
733    fn test_write_u8_mono() {
734        let path = temp_path("tiff_u8");
735        let mut writer = TiffWriter::new();
736
737        let mut arr = NDArray::new(
738            vec![NDDimension::new(4), NDDimension::new(4)],
739            NDDataType::UInt8,
740        );
741        if let NDDataBuffer::U8(v) = &mut arr.data {
742            for i in 0..16 {
743                v[i] = i as u8;
744            }
745        }
746
747        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
748        writer.write_file(&arr).unwrap();
749        writer.close_file().unwrap();
750
751        let data = std::fs::read(&path).unwrap();
752        assert!(data.len() > 16);
753        assert!(
754            &data[0..2] == &[0x49, 0x49] || &data[0..2] == &[0x4D, 0x4D],
755            "Expected TIFF magic bytes"
756        );
757
758        std::fs::remove_file(&path).ok();
759    }
760
761    #[test]
762    fn test_write_u16() {
763        let path = temp_path("tiff_u16");
764        let mut writer = TiffWriter::new();
765
766        let arr = NDArray::new(
767            vec![NDDimension::new(4), NDDimension::new(4)],
768            NDDataType::UInt16,
769        );
770
771        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
772        writer.write_file(&arr).unwrap();
773        writer.close_file().unwrap();
774
775        let data = std::fs::read(&path).unwrap();
776        assert!(data.len() > 32);
777
778        std::fs::remove_file(&path).ok();
779    }
780
781    #[test]
782    fn test_roundtrip_u8() {
783        let path = temp_path("tiff_rt_u8");
784        let mut writer = TiffWriter::new();
785
786        let mut arr = NDArray::new(
787            vec![NDDimension::new(4), NDDimension::new(4)],
788            NDDataType::UInt8,
789        );
790        if let NDDataBuffer::U8(v) = &mut arr.data {
791            for i in 0..16 {
792                v[i] = (i * 10) as u8;
793            }
794        }
795
796        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
797        writer.write_file(&arr).unwrap();
798
799        let read_back = writer.read_file().unwrap();
800        if let (NDDataBuffer::U8(orig), NDDataBuffer::U8(read)) = (&arr.data, &read_back.data) {
801            assert_eq!(orig, read);
802        } else {
803            panic!("data type mismatch on roundtrip");
804        }
805
806        writer.close_file().unwrap();
807        std::fs::remove_file(&path).ok();
808    }
809
810    #[test]
811    fn test_roundtrip_u16() {
812        let path = temp_path("tiff_rt_u16");
813        let mut writer = TiffWriter::new();
814
815        let mut arr = NDArray::new(
816            vec![NDDimension::new(4), NDDimension::new(4)],
817            NDDataType::UInt16,
818        );
819        if let NDDataBuffer::U16(v) = &mut arr.data {
820            for i in 0..16 {
821                v[i] = (i * 1000) as u16;
822            }
823        }
824
825        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
826        writer.write_file(&arr).unwrap();
827
828        let read_back = writer.read_file().unwrap();
829        if let (NDDataBuffer::U16(orig), NDDataBuffer::U16(read)) = (&arr.data, &read_back.data) {
830            assert_eq!(orig, read);
831        } else {
832            panic!("data type mismatch on roundtrip");
833        }
834
835        writer.close_file().unwrap();
836        std::fs::remove_file(&path).ok();
837    }
838
839    #[test]
840    fn test_on_param_change_read_file_emits_array_and_resets_busy() {
841        let path = temp_path("tiff_read_param");
842        let mut writer = TiffWriter::new();
843
844        let mut arr = NDArray::new(
845            vec![NDDimension::new(4), NDDimension::new(3)],
846            NDDataType::UInt8,
847        );
848        arr.unique_id = 77;
849        if let NDDataBuffer::U8(v) = &mut arr.data {
850            for (i, item) in v.iter_mut().enumerate() {
851                *item = i as u8;
852            }
853        }
854
855        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
856        writer.write_file(&arr).unwrap();
857        writer.close_file().unwrap();
858
859        let mut base = PortDriverBase::new("TIFFTEST", 1, PortFlags::default());
860        let _nd_params = NDArrayDriverParams::create(&mut base).unwrap();
861
862        let mut proc = TiffFileProcessor::new();
863        proc.register_params(&mut base).unwrap();
864
865        let reason_path = base.find_param("FILE_PATH").unwrap();
866        let reason_name = base.find_param("FILE_NAME").unwrap();
867        let reason_template = base.find_param("FILE_TEMPLATE").unwrap();
868        let reason_read = base.find_param("READ_FILE").unwrap();
869
870        let _ = proc.on_param_change(
871            reason_path,
872            &PluginParamSnapshot {
873                enable_callbacks: true,
874                reason: reason_path,
875                addr: 0,
876                value: ParamChangeValue::Octet(
877                    path.parent().unwrap().to_str().unwrap().to_string(),
878                ),
879            },
880        );
881        let _ = proc.on_param_change(
882            reason_name,
883            &PluginParamSnapshot {
884                enable_callbacks: true,
885                reason: reason_name,
886                addr: 0,
887                value: ParamChangeValue::Octet(
888                    path.file_name().unwrap().to_str().unwrap().to_string(),
889                ),
890            },
891        );
892        let _ = proc.on_param_change(
893            reason_template,
894            &PluginParamSnapshot {
895                enable_callbacks: true,
896                reason: reason_template,
897                addr: 0,
898                value: ParamChangeValue::Octet("%s%s".into()),
899            },
900        );
901
902        let result = proc.on_param_change(
903            reason_read,
904            &PluginParamSnapshot {
905                enable_callbacks: true,
906                reason: reason_read,
907                addr: 0,
908                value: ParamChangeValue::Int32(1),
909            },
910        );
911
912        assert_eq!(result.output_arrays.len(), 1);
913        assert!(result.param_updates.iter().any(|u| matches!(
914            u,
915            ParamUpdate::Int32 { reason, value: 0, .. } if *reason == reason_read
916        )));
917        match &result.output_arrays[0].data {
918            NDDataBuffer::U8(v) => assert_eq!(v.len(), 12),
919            other => panic!("unexpected data buffer: {other:?}"),
920        }
921
922        std::fs::remove_file(&path).ok();
923    }
924
925    #[test]
926    fn test_metadata_tags_match_cpp_numbers_and_types() {
927        let path = temp_path("tiff_meta_tags");
928        let mut writer = TiffWriter::new();
929
930        let mut arr = NDArray::new(
931            vec![NDDimension::new(4), NDDimension::new(4)],
932            NDDataType::UInt8,
933        );
934        arr.unique_id = 4242;
935        arr.time_stamp = 1234.5;
936        arr.timestamp.sec = 1_000_000;
937        arr.timestamp.nsec = 500;
938
939        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
940        writer.write_file(&arr).unwrap();
941        writer.close_file().unwrap();
942
943        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
944        // 65000 = NDTimeStamp (TIFF_DOUBLE).
945        assert_eq!(decoder.get_tag_f64(Tag::Unknown(65000)).unwrap(), 1234.5);
946        // 65001 = NDUniqueId (TIFF_LONG).
947        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65001)).unwrap(), 4242);
948        // 65002 = EPICSTSSec, 65003 = EPICSTSNsec.
949        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65002)).unwrap(), 1_000_000);
950        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65003)).unwrap(), 500);
951        // Standard Software tag.
952        assert_eq!(
953            decoder
954                .get_tag(Tag::Software)
955                .unwrap()
956                .into_string()
957                .unwrap(),
958            "EPICS areaDetector"
959        );
960
961        std::fs::remove_file(&path).ok();
962    }
963
964    #[test]
965    fn test_standard_tags_from_attributes() {
966        let path = temp_path("tiff_std_tags");
967        let mut writer = TiffWriter::new();
968
969        let mut arr = NDArray::new(
970            vec![NDDimension::new(4), NDDimension::new(4)],
971            NDDataType::UInt8,
972        );
973        arr.attributes.add(NDAttribute::new_static(
974            "Model",
975            "",
976            NDAttrSource::Driver,
977            NDAttrValue::String("SimDetector".into()),
978        ));
979        arr.attributes.add(NDAttribute::new_static(
980            "Manufacturer",
981            "",
982            NDAttrSource::Driver,
983            NDAttrValue::String("EPICS".into()),
984        ));
985        arr.attributes.add(NDAttribute::new_static(
986            "TIFFImageDescription",
987            "",
988            NDAttrSource::Driver,
989            NDAttrValue::String("test frame".into()),
990        ));
991
992        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
993        writer.write_file(&arr).unwrap();
994        writer.close_file().unwrap();
995
996        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
997        assert_eq!(
998            decoder.get_tag(Tag::Model).unwrap().into_string().unwrap(),
999            "SimDetector"
1000        );
1001        assert_eq!(
1002            decoder.get_tag(Tag::Make).unwrap().into_string().unwrap(),
1003            "EPICS"
1004        );
1005        assert_eq!(
1006            decoder
1007                .get_tag(Tag::ImageDescription)
1008                .unwrap()
1009                .into_string()
1010                .unwrap(),
1011            "test frame"
1012        );
1013
1014        std::fs::remove_file(&path).ok();
1015    }
1016
1017    #[test]
1018    fn test_attribute_tag_format_uses_colon_and_type() {
1019        // C++ uses "name:value" with typed numeric formatting.
1020        let mut a = NDArray::new(
1021            vec![NDDimension::new(2), NDDimension::new(2)],
1022            NDDataType::UInt8,
1023        );
1024        a.attributes.add(NDAttribute::new_static(
1025            "Gain",
1026            "",
1027            NDAttrSource::Driver,
1028            NDAttrValue::Int32(-7),
1029        ));
1030
1031        let path = temp_path("tiff_attr_fmt");
1032        let mut writer = TiffWriter::new();
1033        writer.open_file(&path, NDFileMode::Single, &a).unwrap();
1034        writer.write_file(&a).unwrap();
1035        writer.close_file().unwrap();
1036
1037        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
1038        // First attribute tag is 65010.
1039        let s = decoder
1040            .get_tag(Tag::Unknown(65010))
1041            .unwrap()
1042            .into_string()
1043            .unwrap();
1044        assert_eq!(s, "Gain:-7");
1045
1046        std::fs::remove_file(&path).ok();
1047    }
1048
1049    #[test]
1050    fn test_signed_rgb_writes_instead_of_erroring() {
1051        let path = temp_path("tiff_signed_rgb");
1052        let mut writer = TiffWriter::new();
1053
1054        let mut arr = NDArray::new(
1055            vec![
1056                NDDimension::new(3),
1057                NDDimension::new(2),
1058                NDDimension::new(2),
1059            ],
1060            NDDataType::Int16,
1061        );
1062        TiffWriter::attach_color_mode(&mut arr, NDColorMode::RGB1);
1063        if let NDDataBuffer::I16(v) = &mut arr.data {
1064            for (i, item) in v.iter_mut().enumerate() {
1065                *item = (i as i16) - 6;
1066            }
1067        }
1068
1069        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
1070        // Previously a hard error; must now succeed.
1071        writer.write_file(&arr).unwrap();
1072        writer.close_file().unwrap();
1073
1074        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
1075        let sf = decoder.get_tag_u16_vec(Tag::SampleFormat).unwrap();
1076        // SampleFormat 2 = SAMPLEFORMAT_INT.
1077        assert!(sf.iter().all(|&s| s == 2), "expected signed sample format");
1078
1079        std::fs::remove_file(&path).ok();
1080    }
1081
1082    // ---- R8-67: PlanarConfiguration, separate RGB2/RGB3 planes, RowsPerStrip ----
1083
1084    /// An RGB image in one of the three AD layouts; `pixel(x, y, c)` is
1085    /// deterministic so every layout holds the same pixels, ordered differently.
1086    fn rgb_array(mode: NDColorMode, w: usize, h: usize) -> NDArray {
1087        let dims = match mode {
1088            NDColorMode::RGB1 => vec![3, w, h],
1089            NDColorMode::RGB2 => vec![w, 3, h],
1090            NDColorMode::RGB3 => vec![w, h, 3],
1091            other => panic!("not an RGB layout: {other:?}"),
1092        };
1093        let mut arr = NDArray::new(
1094            dims.into_iter().map(NDDimension::new).collect(),
1095            NDDataType::UInt8,
1096        );
1097        TiffWriter::attach_color_mode(&mut arr, mode);
1098        if let NDDataBuffer::U8(v) = &mut arr.data {
1099            for y in 0..h {
1100                for x in 0..w {
1101                    for c in 0..3 {
1102                        let idx = match mode {
1103                            NDColorMode::RGB1 => c + x * 3 + y * w * 3,
1104                            NDColorMode::RGB2 => x + c * w + y * w * 3,
1105                            NDColorMode::RGB3 => x + y * w + c * w * h,
1106                            _ => unreachable!(),
1107                        };
1108                        v[idx] = pixel(x, y, c);
1109                    }
1110                }
1111            }
1112        }
1113        arr
1114    }
1115
1116    fn pixel(x: usize, y: usize, c: usize) -> u8 {
1117        ((x * 7 + y * 13 + c * 61) % 256) as u8
1118    }
1119
1120    fn mono_array(w: usize, h: usize) -> NDArray {
1121        let mut arr = NDArray::new(
1122            vec![NDDimension::new(w), NDDimension::new(h)],
1123            NDDataType::UInt8,
1124        );
1125        if let NDDataBuffer::U8(v) = &mut arr.data {
1126            v.iter_mut().enumerate().for_each(|(i, x)| *x = i as u8);
1127        }
1128        arr
1129    }
1130
1131    /// The image bytes as a reader gets them: every strip, in **strip index**
1132    /// order (not file order), concatenated — exactly what C's readFile does with
1133    /// TIFFReadEncodedStrip (NDFileTIFF.cpp:513-524). Offsets and counts come
1134    /// from an independent TIFF decoder.
1135    fn strips_in_index_order(path: &Path) -> Vec<u8> {
1136        let bytes = std::fs::read(path).unwrap();
1137        let mut decoder = Decoder::new(std::fs::File::open(path).unwrap()).unwrap();
1138        let offsets = decoder.get_tag_u32_vec(Tag::StripOffsets).unwrap();
1139        let counts = decoder.get_tag_u32_vec(Tag::StripByteCounts).unwrap();
1140        assert_eq!(offsets.len(), counts.len());
1141        let mut out = Vec::new();
1142        for (off, count) in offsets.iter().zip(&counts) {
1143            out.extend_from_slice(&bytes[*off as usize..(*off + *count) as usize]);
1144        }
1145        out
1146    }
1147
1148    fn write_tiff(prefix: &str, array: &NDArray) -> PathBuf {
1149        let path = temp_path(prefix);
1150        let mut writer = TiffWriter::new();
1151        writer.open_file(&path, NDFileMode::Single, array).unwrap();
1152        writer.write_file(array).unwrap();
1153        writer.close_file().unwrap();
1154        path
1155    }
1156
1157    #[test]
1158    fn test_r8_67_every_image_carries_planarconfiguration() {
1159        // C sets TIFFTAG_PLANARCONFIG on every image (NDFileTIFF.cpp:229):
1160        // CONTIG(1) for mono and RGB1, SEPARATE(2) for RGB2 and RGB3. The port
1161        // emitted tag 284 on no image at all.
1162        for (name, array, expected) in [
1163            ("planar_mono", mono_array(4, 3), PLANARCONFIG_CONTIG),
1164            (
1165                "planar_rgb1",
1166                rgb_array(NDColorMode::RGB1, 4, 3),
1167                PLANARCONFIG_CONTIG,
1168            ),
1169            (
1170                "planar_rgb2",
1171                rgb_array(NDColorMode::RGB2, 4, 3),
1172                PLANARCONFIG_SEPARATE,
1173            ),
1174            (
1175                "planar_rgb3",
1176                rgb_array(NDColorMode::RGB3, 4, 3),
1177                PLANARCONFIG_SEPARATE,
1178            ),
1179        ] {
1180            let path = write_tiff(name, &array);
1181            let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
1182            assert_eq!(
1183                decoder.get_tag_u32(Tag::PlanarConfiguration).unwrap() as u16,
1184                expected,
1185                "{name}: PlanarConfiguration (tag 284) must be on disk with C's value"
1186            );
1187            std::fs::remove_file(&path).ok();
1188        }
1189    }
1190
1191    #[test]
1192    fn test_r8_67_rgb2_and_rgb3_are_written_as_three_separate_planes() {
1193        // C writes RGB2/RGB3 as three separate colour planes (:389-405): reading
1194        // the strips in index order yields plane R, then G, then B. The port
1195        // converted both to RGB1 and wrote chunky RGBRGB… bytes instead.
1196        let (w, h) = (4usize, 3usize);
1197        let mut expected_planes: Vec<u8> = Vec::new();
1198        for c in 0..3 {
1199            for y in 0..h {
1200                for x in 0..w {
1201                    expected_planes.push(pixel(x, y, c));
1202                }
1203            }
1204        }
1205
1206        for (name, mode) in [
1207            ("sep_rgb2", NDColorMode::RGB2),
1208            ("sep_rgb3", NDColorMode::RGB3),
1209        ] {
1210            let path = write_tiff(name, &rgb_array(mode, w, h));
1211            assert_eq!(
1212                strips_in_index_order(&path),
1213                expected_planes,
1214                "{mode:?}: on-disk strips must be the R, G and B planes in order"
1215            );
1216            std::fs::remove_file(&path).ok();
1217        }
1218
1219        // RGB1 stays chunky: the single strip holds pixel-interleaved RGB.
1220        let mut expected_chunky: Vec<u8> = Vec::new();
1221        for y in 0..h {
1222            for x in 0..w {
1223                for c in 0..3 {
1224                    expected_chunky.push(pixel(x, y, c));
1225                }
1226            }
1227        }
1228        let path = write_tiff("sep_rgb1", &rgb_array(NDColorMode::RGB1, w, h));
1229        assert_eq!(strips_in_index_order(&path), expected_chunky);
1230        std::fs::remove_file(&path).ok();
1231    }
1232
1233    #[test]
1234    fn test_r8_67_rows_per_strip_matches_c() {
1235        // C: rowsPerStrip = sizeY for mono/RGB1/RGB3, but 1 for RGB2 because its
1236        // rows interleave the planes (:190-219). The `tiff` crate instead chose a
1237        // ~1 MB strip height, so the tag value (and the strip count) diverged on
1238        // every image.
1239        let (w, h) = (4usize, 3usize);
1240        for (name, array, rows, strips) in [
1241            ("rps_mono", mono_array(w, h), h as u32, 1),
1242            ("rps_rgb1", rgb_array(NDColorMode::RGB1, w, h), h as u32, 1),
1243            ("rps_rgb2", rgb_array(NDColorMode::RGB2, w, h), 1, 3 * h),
1244            ("rps_rgb3", rgb_array(NDColorMode::RGB3, w, h), h as u32, 3),
1245        ] {
1246            let path = write_tiff(name, &array);
1247            let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
1248            assert_eq!(
1249                decoder.get_tag_u32(Tag::RowsPerStrip).unwrap(),
1250                rows,
1251                "{name}: RowsPerStrip"
1252            );
1253            assert_eq!(
1254                decoder.get_tag_u32_vec(Tag::StripOffsets).unwrap().len(),
1255                strips,
1256                "{name}: strip count"
1257            );
1258            std::fs::remove_file(&path).ok();
1259        }
1260    }
1261
1262    #[test]
1263    fn test_r8_67_no_resolution_tags() {
1264        // C never calls TIFFSetField for XResolution/YResolution/ResolutionUnit,
1265        // so a C-written file carries none of them; the `tiff` crate emitted all
1266        // three (1/1, 1/1, unit None) on every image.
1267        let path = write_tiff("no_resolution", &rgb_array(NDColorMode::RGB1, 4, 3));
1268        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
1269        for tag in [Tag::XResolution, Tag::YResolution, Tag::ResolutionUnit] {
1270            assert!(
1271                decoder.get_tag(tag).is_err(),
1272                "{tag:?} must not be written — C sets no resolution tags"
1273            );
1274        }
1275        std::fs::remove_file(&path).ok();
1276    }
1277
1278    #[test]
1279    fn test_r8_67_planar_file_reads_back_as_rgb3() {
1280        // C's readFile maps RGB/SEPARATE/3 to RGB3 dims [x, y, 3] (:497-505), so
1281        // an RGB2 frame round-trips as RGB3 holding the same pixels. Our reader
1282        // must do the same — the `tiff` crate's decoder reads only the first band
1283        // of a planar image, which is why read_file is hand-written too.
1284        let (w, h) = (4usize, 3usize);
1285        let path = write_tiff("planar_read", &rgb_array(NDColorMode::RGB2, w, h));
1286
1287        let mut writer = TiffWriter::new();
1288        writer
1289            .open_file(
1290                &path,
1291                NDFileMode::Single,
1292                &NDArray::new(vec![], NDDataType::UInt8),
1293            )
1294            .unwrap();
1295        let read_back = writer.read_file().unwrap();
1296        writer.close_file().unwrap();
1297
1298        assert_eq!(
1299            read_back.dims.iter().map(|d| d.size).collect::<Vec<_>>(),
1300            vec![w, h, 3]
1301        );
1302        assert_eq!(
1303            read_back
1304                .attributes
1305                .get("ColorMode")
1306                .unwrap()
1307                .value
1308                .as_i64(),
1309            Some(NDColorMode::RGB3 as i64)
1310        );
1311        assert_eq!(
1312            read_back.data.as_u8_slice(),
1313            rgb_array(NDColorMode::RGB3, w, h).data.as_u8_slice(),
1314            "the RGB2 pixels must come back as the same image in RGB3 layout"
1315        );
1316        std::fs::remove_file(&path).ok();
1317    }
1318
1319    #[test]
1320    fn test_single_mode_requires_auto_save_for_automatic_write() {
1321        let path = temp_path("tiff_autosave_single");
1322        let full_name = path.to_string_lossy().to_string();
1323        let file_path = path.parent().unwrap().to_str().unwrap().to_string();
1324        let file_name = path.file_name().unwrap().to_str().unwrap().to_string();
1325
1326        let proc = TiffFileProcessor::new();
1327        proc.ctrl.lock().file_base.file_path = file_path.clone() + "/";
1328        proc.ctrl.lock().file_base.file_name = file_name;
1329        proc.ctrl.lock().file_base.file_template = "%s%s".into();
1330        proc.ctrl.lock().file_base.set_mode(NDFileMode::Single);
1331
1332        let mut arr = NDArray::new(
1333            vec![NDDimension::new(4), NDDimension::new(4)],
1334            NDDataType::UInt8,
1335        );
1336        if let NDDataBuffer::U8(v) = &mut arr.data {
1337            for (i, item) in v.iter_mut().enumerate() {
1338                *item = i as u8;
1339            }
1340        }
1341
1342        proc.ctrl.lock().auto_save = false;
1343        let _ = proc.process_array(&arr, &NDArrayPool::new(1024));
1344        assert!(!std::path::Path::new(&full_name).exists());
1345
1346        proc.ctrl.lock().auto_save = true;
1347        let _ = proc.process_array(&arr, &NDArrayPool::new(1024));
1348        assert!(std::path::Path::new(&full_name).exists());
1349
1350        std::fs::remove_file(&path).ok();
1351    }
1352}