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