Skip to main content

ad_plugins_rs/
file_tiff.rs

1use std::path::{Path, PathBuf};
2
3use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
4use ad_core_rs::color::{NDColorMode, convert_rgb_layout};
5use ad_core_rs::error::{ADError, ADResult};
6use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
7use ad_core_rs::ndarray_pool::NDArrayPool;
8use ad_core_rs::plugin::file_base::{NDFileMode, NDFileWriter};
9use ad_core_rs::plugin::file_controller::FilePluginController;
10use ad_core_rs::plugin::runtime::{
11    NDPluginProcess, ParamChangeResult, PluginParamSnapshot, ProcessResult,
12};
13
14use tiff::ColorType;
15use tiff::decoder::Decoder;
16use tiff::encoder::TiffEncoder;
17use tiff::encoder::colortype;
18use tiff::tags::Tag;
19
20// Custom TIFF tag numbers — must match C++ NDFileTIFF.cpp:37-41 exactly.
21const TIFFTAG_NDTIMESTAMP: u16 = 65000;
22const TIFFTAG_UNIQUEID: u16 = 65001;
23const TIFFTAG_EPICSTSSEC: u16 = 65002;
24const TIFFTAG_EPICSTSNSEC: u16 = 65003;
25const TIFFTAG_FIRST_ATTRIBUTE: u16 = 65010;
26
27/// Signed-RGB color types. The `tiff` crate only ships unsigned RGB
28/// (`colortype::RGB8/16/32`); C++ libtiff handles signed RGB by setting
29/// `SAMPLEFORMAT_INT` on an otherwise identical RGB layout. These local
30/// `ColorType` impls do exactly that — same bit layout, `SampleFormat::Int`.
31mod signed_rgb {
32    use tiff::encoder::colortype::ColorType;
33    use tiff::tags::{PhotometricInterpretation, SampleFormat};
34
35    macro_rules! signed_rgb {
36        ($name:ident, $inner:ty, $bits:expr) => {
37            pub struct $name;
38            impl ColorType for $name {
39                type Inner = $inner;
40                const TIFF_VALUE: PhotometricInterpretation = PhotometricInterpretation::RGB;
41                const BITS_PER_SAMPLE: &'static [u16] = &[$bits, $bits, $bits];
42                const SAMPLE_FORMAT: &'static [SampleFormat] =
43                    &[SampleFormat::Int, SampleFormat::Int, SampleFormat::Int];
44            }
45        };
46    }
47
48    signed_rgb!(RGBI8, i8, 8);
49    signed_rgb!(RGBI16, i16, 16);
50    signed_rgb!(RGBI32, i32, 32);
51    signed_rgb!(RGBI64, i64, 64);
52}
53
54/// Format an NDAttribute value as the C++ `epicsSnprintf` "name:value" tag
55/// string (NDFileTIFF.cpp:303-327). Numeric values keep their type, not a
56/// generic stringification: signed `%lld`, unsigned `%llu`, float `%f`.
57fn attribute_tag_string(attr: &NDAttribute) -> String {
58    let value = match &attr.value {
59        NDAttrValue::Int8(v) => format!("{}", v),
60        NDAttrValue::Int16(v) => format!("{}", v),
61        NDAttrValue::Int32(v) => format!("{}", v),
62        NDAttrValue::Int64(v) => format!("{}", v),
63        NDAttrValue::UInt8(v) => format!("{}", v),
64        NDAttrValue::UInt16(v) => format!("{}", v),
65        NDAttrValue::UInt32(v) => format!("{}", v),
66        NDAttrValue::UInt64(v) => format!("{}", v),
67        // C++ uses "%f" which is 6 fractional digits.
68        NDAttrValue::Float32(v) => format!("{:.6}", v),
69        NDAttrValue::Float64(v) => format!("{:.6}", v),
70        NDAttrValue::String(s) => s.clone(),
71        NDAttrValue::Undefined => String::new(),
72    };
73    format!("{}:{}", attr.name, value)
74}
75
76/// TIFF file writer using the `tiff` crate for proper encoding/decoding.
77pub struct TiffWriter {
78    current_path: Option<PathBuf>,
79}
80
81impl TiffWriter {
82    pub fn new() -> Self {
83        Self { current_path: None }
84    }
85
86    fn array_color_mode(array: &NDArray) -> NDColorMode {
87        array
88            .attributes
89            .get("ColorMode")
90            .and_then(|attr| attr.value.as_i64())
91            .map(|v| NDColorMode::from_i32(v as i32))
92            .unwrap_or_else(|| match array.dims.as_slice() {
93                [a, _, _] if a.size == 3 => NDColorMode::RGB1,
94                [_, b, _] if b.size == 3 => NDColorMode::RGB2,
95                [_, _, c] if c.size == 3 => NDColorMode::RGB3,
96                _ => NDColorMode::Mono,
97            })
98    }
99
100    fn normalize_for_write(array: &NDArray) -> ADResult<(NDArray, u32, u32, bool)> {
101        match array.dims.as_slice() {
102            [x] => {
103                let mut normalized = NDArray::new(
104                    vec![NDDimension::new(x.size), NDDimension::new(1)],
105                    array.data.data_type(),
106                );
107                normalized.data = array.data.clone();
108                normalized.unique_id = array.unique_id;
109                normalized.timestamp = array.timestamp;
110                normalized.attributes = array.attributes.clone();
111                normalized.codec = array.codec.clone();
112                Ok((normalized, x.size as u32, 1, false))
113            }
114            [x, y] => Ok((array.clone(), x.size as u32, y.size as u32, false)),
115            [_, _, _] => {
116                let color_mode = Self::array_color_mode(array);
117                let rgb1 = match color_mode {
118                    NDColorMode::RGB1 => array.clone(),
119                    NDColorMode::RGB2 | NDColorMode::RGB3 => {
120                        convert_rgb_layout(array, color_mode, NDColorMode::RGB1)?
121                    }
122                    other => {
123                        return Err(ADError::UnsupportedConversion(format!(
124                            "unsupported TIFF color mode: {:?}",
125                            other
126                        )));
127                    }
128                };
129                Ok((
130                    rgb1.clone(),
131                    rgb1.dims[1].size as u32,
132                    rgb1.dims[2].size as u32,
133                    true,
134                ))
135            }
136            _ => Err(ADError::InvalidDimensions(
137                "unsupported TIFF array dimensions".into(),
138            )),
139        }
140    }
141
142    fn attach_color_mode(array: &mut NDArray, color_mode: NDColorMode) {
143        array.attributes.add(NDAttribute::new_static(
144            "ColorMode",
145            "Color mode",
146            NDAttrSource::Driver,
147            NDAttrValue::Int32(color_mode as i32),
148        ));
149    }
150}
151
152impl NDFileWriter for TiffWriter {
153    fn open_file(&mut self, path: &Path, _mode: NDFileMode, _array: &NDArray) -> ADResult<()> {
154        self.current_path = Some(path.to_path_buf());
155        Ok(())
156    }
157
158    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
159        let path = self
160            .current_path
161            .as_ref()
162            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
163        let (array, width, height, is_rgb) = Self::normalize_for_write(array)?;
164
165        let file = std::fs::File::create(path)?;
166        let mut encoder = TiffEncoder::new(file)
167            .map_err(|e| ADError::UnsupportedConversion(format!("TIFF encoder error: {}", e)))?;
168
169        // Collect attribute tag data before borrowing encoder mutably.
170        // C++ writes NDArray attributes as custom TIFF tags starting at tag
171        // 65010 (TIFFTAG_FIRST_ATTRIBUTE), value format "name:value" (colon).
172        let attr_tags: Vec<(u16, String)> = array
173            .attributes
174            .iter()
175            .enumerate()
176            .map(|(i, attr)| {
177                let tag_num = TIFFTAG_FIRST_ATTRIBUTE.saturating_add(i as u16);
178                (tag_num, attribute_tag_string(attr))
179            })
180            .collect();
181
182        // Standard tags derived from well-known attributes (NDFileTIFF.cpp:243-271).
183        let model = array
184            .attributes
185            .get("Model")
186            .map(|a| a.value.as_string())
187            .unwrap_or_else(|| "Unknown".to_string());
188        let make = array
189            .attributes
190            .get("Manufacturer")
191            .map(|a| a.value.as_string())
192            .unwrap_or_else(|| "Unknown".to_string());
193        let image_description = array
194            .attributes
195            .get("TIFFImageDescription")
196            .map(|a| a.value.as_string());
197
198        let unique_id = array.unique_id;
199        let time_stamp = array.time_stamp;
200        let ts_sec = array.timestamp.sec;
201        let ts_nsec = array.timestamp.nsec;
202
203        // Macro to reduce repetition: create image encoder, write custom tags, write data.
204        macro_rules! write_with_tags {
205            ($ct:ty, $data:expr) => {{
206                let mut image = encoder.new_image::<$ct>(width, height).map_err(|e| {
207                    ADError::UnsupportedConversion(format!("TIFF encoder error: {}", e))
208                })?;
209
210                macro_rules! tag {
211                    ($tag:expr, $val:expr) => {
212                        image.encoder().write_tag($tag, $val).map_err(|e| {
213                            ADError::UnsupportedConversion(format!("TIFF tag write error: {}", e))
214                        })?;
215                    };
216                }
217
218                // EPICS metadata tags 65000-65003 — typed values matching C++.
219                tag!(Tag::Unknown(TIFFTAG_NDTIMESTAMP), time_stamp);
220                tag!(Tag::Unknown(TIFFTAG_UNIQUEID), unique_id as u32);
221                tag!(Tag::Unknown(TIFFTAG_EPICSTSSEC), ts_sec);
222                tag!(Tag::Unknown(TIFFTAG_EPICSTSNSEC), ts_nsec);
223
224                // Standard tags (NDFileTIFF.cpp:243-271).
225                tag!(Tag::Software, "EPICS areaDetector");
226                tag!(Tag::Model, &*model);
227                tag!(Tag::Make, &*make);
228                if let Some(desc) = &image_description {
229                    tag!(Tag::ImageDescription, &**desc);
230                }
231
232                // NDArray attributes as custom tags starting at 65010.
233                for (tag_num, tag_val) in &attr_tags {
234                    tag!(Tag::Unknown(*tag_num), &**tag_val);
235                }
236
237                image
238                    .write_data($data)
239                    .map_err(|e| ADError::UnsupportedConversion(format!("TIFF write error: {}", e)))
240            }};
241        }
242
243        match &array.data {
244            NDDataBuffer::U8(v) => {
245                if is_rgb {
246                    write_with_tags!(colortype::RGB8, v)
247                } else {
248                    write_with_tags!(colortype::Gray8, v)
249                }
250            }
251            NDDataBuffer::I8(v) => {
252                if is_rgb {
253                    write_with_tags!(signed_rgb::RGBI8, v)
254                } else {
255                    write_with_tags!(colortype::GrayI8, v)
256                }
257            }
258            NDDataBuffer::U16(v) => {
259                if is_rgb {
260                    write_with_tags!(colortype::RGB16, v)
261                } else {
262                    write_with_tags!(colortype::Gray16, v)
263                }
264            }
265            NDDataBuffer::I16(v) => {
266                if is_rgb {
267                    write_with_tags!(signed_rgb::RGBI16, v)
268                } else {
269                    write_with_tags!(colortype::GrayI16, v)
270                }
271            }
272            NDDataBuffer::U32(v) => {
273                if is_rgb {
274                    write_with_tags!(colortype::RGB32, v)
275                } else {
276                    write_with_tags!(colortype::Gray32, v)
277                }
278            }
279            NDDataBuffer::I32(v) => {
280                if is_rgb {
281                    write_with_tags!(signed_rgb::RGBI32, v)
282                } else {
283                    write_with_tags!(colortype::GrayI32, v)
284                }
285            }
286            NDDataBuffer::I64(v) => {
287                if is_rgb {
288                    write_with_tags!(signed_rgb::RGBI64, v)
289                } else {
290                    write_with_tags!(colortype::GrayI64, v)
291                }
292            }
293            NDDataBuffer::U64(v) => {
294                if is_rgb {
295                    write_with_tags!(colortype::RGB64, v)
296                } else {
297                    write_with_tags!(colortype::Gray64, v)
298                }
299            }
300            NDDataBuffer::F32(v) => {
301                if is_rgb {
302                    write_with_tags!(colortype::RGB32Float, v)
303                } else {
304                    write_with_tags!(colortype::Gray32Float, v)
305                }
306            }
307            NDDataBuffer::F64(v) => {
308                if is_rgb {
309                    write_with_tags!(colortype::RGB64Float, v)
310                } else {
311                    write_with_tags!(colortype::Gray64Float, v)
312                }
313            }
314        }?;
315
316        Ok(())
317    }
318
319    fn read_file(&mut self) -> ADResult<NDArray> {
320        let path = self
321            .current_path
322            .as_ref()
323            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
324
325        let file = std::fs::File::open(path)?;
326        let mut decoder = Decoder::new(file)
327            .map_err(|e| ADError::UnsupportedConversion(format!("TIFF decode error: {}", e)))?;
328
329        let (width, height) = decoder
330            .dimensions()
331            .map_err(|e| ADError::UnsupportedConversion(format!("TIFF dimensions error: {}", e)))?;
332        let color_type = decoder
333            .colortype()
334            .map_err(|e| ADError::UnsupportedConversion(format!("TIFF colortype error: {}", e)))?;
335
336        let result = decoder
337            .read_image()
338            .map_err(|e| ADError::UnsupportedConversion(format!("TIFF read error: {}", e)))?;
339
340        let (dims, color_mode) = match color_type {
341            ColorType::Gray(_) => (
342                vec![
343                    NDDimension::new(width as usize),
344                    NDDimension::new(height as usize),
345                ],
346                NDColorMode::Mono,
347            ),
348            ColorType::RGB(_) => (
349                vec![
350                    NDDimension::new(3),
351                    NDDimension::new(width as usize),
352                    NDDimension::new(height as usize),
353                ],
354                NDColorMode::RGB1,
355            ),
356            other => {
357                return Err(ADError::UnsupportedConversion(format!(
358                    "unsupported TIFF color type: {:?}",
359                    other
360                )));
361            }
362        };
363
364        let mut array = match result {
365            tiff::decoder::DecodingResult::U8(data) => {
366                let mut arr = NDArray::new(dims.clone(), NDDataType::UInt8);
367                arr.data = NDDataBuffer::U8(data);
368                arr
369            }
370            tiff::decoder::DecodingResult::U16(data) => {
371                let mut arr = NDArray::new(dims.clone(), NDDataType::UInt16);
372                arr.data = NDDataBuffer::U16(data);
373                arr
374            }
375            tiff::decoder::DecodingResult::U32(data) => {
376                let mut arr = NDArray::new(dims.clone(), NDDataType::UInt32);
377                arr.data = NDDataBuffer::U32(data);
378                arr
379            }
380            tiff::decoder::DecodingResult::U64(data) => {
381                let mut arr = NDArray::new(dims.clone(), NDDataType::UInt64);
382                arr.data = NDDataBuffer::U64(data);
383                arr
384            }
385            tiff::decoder::DecodingResult::I8(data) => {
386                let mut arr = NDArray::new(dims.clone(), NDDataType::Int8);
387                arr.data = NDDataBuffer::I8(data);
388                arr
389            }
390            tiff::decoder::DecodingResult::I16(data) => {
391                let mut arr = NDArray::new(dims.clone(), NDDataType::Int16);
392                arr.data = NDDataBuffer::I16(data);
393                arr
394            }
395            tiff::decoder::DecodingResult::I32(data) => {
396                let mut arr = NDArray::new(dims.clone(), NDDataType::Int32);
397                arr.data = NDDataBuffer::I32(data);
398                arr
399            }
400            tiff::decoder::DecodingResult::I64(data) => {
401                let mut arr = NDArray::new(dims.clone(), NDDataType::Int64);
402                arr.data = NDDataBuffer::I64(data);
403                arr
404            }
405            tiff::decoder::DecodingResult::F32(data) => {
406                let mut arr = NDArray::new(dims.clone(), NDDataType::Float32);
407                arr.data = NDDataBuffer::F32(data);
408                arr
409            }
410            tiff::decoder::DecodingResult::F64(data) => {
411                let mut arr = NDArray::new(dims.clone(), NDDataType::Float64);
412                arr.data = NDDataBuffer::F64(data);
413                arr
414            }
415        };
416        Self::attach_color_mode(&mut array, color_mode);
417        Ok(array)
418    }
419
420    fn close_file(&mut self) -> ADResult<()> {
421        self.current_path = None;
422        Ok(())
423    }
424
425    fn supports_multiple_arrays(&self) -> bool {
426        false
427    }
428}
429
430/// TIFF file processor wrapping FilePluginController<TiffWriter>.
431pub struct TiffFileProcessor {
432    pub ctrl: FilePluginController<TiffWriter>,
433}
434
435impl TiffFileProcessor {
436    pub fn new() -> Self {
437        Self {
438            ctrl: FilePluginController::new(TiffWriter::new()),
439        }
440    }
441}
442
443impl Default for TiffFileProcessor {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449impl NDPluginProcess for TiffFileProcessor {
450    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
451        self.ctrl.process_array(array)
452    }
453
454    fn plugin_type(&self) -> &str {
455        "NDFileTIFF"
456    }
457
458    /// C `NDPluginFile.cpp:948` (base of every file writer) sets
459    /// `NDArrayCallbacks = 0`: file plugins write to disk, not downstream.
460    fn does_array_callbacks(&self) -> bool {
461        false
462    }
463
464    fn register_params(
465        &mut self,
466        base: &mut asyn_rs::port::PortDriverBase,
467    ) -> asyn_rs::error::AsynResult<()> {
468        self.ctrl.register_params(base)
469    }
470
471    fn on_param_change(
472        &mut self,
473        reason: usize,
474        params: &PluginParamSnapshot,
475    ) -> ParamChangeResult {
476        self.ctrl.on_param_change(reason, params)
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use ad_core_rs::ndarray::NDDataBuffer;
484    use ad_core_rs::params::ndarray_driver::NDArrayDriverParams;
485    use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
486    use asyn_rs::port::{PortDriverBase, PortFlags};
487    use std::sync::atomic::{AtomicU32, Ordering};
488
489    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
490
491    fn temp_path(prefix: &str) -> PathBuf {
492        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
493        std::env::temp_dir().join(format!("adcore_test_{}_{}.tif", prefix, n))
494    }
495
496    #[test]
497    fn test_write_u8_mono() {
498        let path = temp_path("tiff_u8");
499        let mut writer = TiffWriter::new();
500
501        let mut arr = NDArray::new(
502            vec![NDDimension::new(4), NDDimension::new(4)],
503            NDDataType::UInt8,
504        );
505        if let NDDataBuffer::U8(v) = &mut arr.data {
506            for i in 0..16 {
507                v[i] = i as u8;
508            }
509        }
510
511        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
512        writer.write_file(&arr).unwrap();
513        writer.close_file().unwrap();
514
515        let data = std::fs::read(&path).unwrap();
516        assert!(data.len() > 16);
517        assert!(
518            &data[0..2] == &[0x49, 0x49] || &data[0..2] == &[0x4D, 0x4D],
519            "Expected TIFF magic bytes"
520        );
521
522        std::fs::remove_file(&path).ok();
523    }
524
525    #[test]
526    fn test_write_u16() {
527        let path = temp_path("tiff_u16");
528        let mut writer = TiffWriter::new();
529
530        let arr = NDArray::new(
531            vec![NDDimension::new(4), NDDimension::new(4)],
532            NDDataType::UInt16,
533        );
534
535        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
536        writer.write_file(&arr).unwrap();
537        writer.close_file().unwrap();
538
539        let data = std::fs::read(&path).unwrap();
540        assert!(data.len() > 32);
541
542        std::fs::remove_file(&path).ok();
543    }
544
545    #[test]
546    fn test_roundtrip_u8() {
547        let path = temp_path("tiff_rt_u8");
548        let mut writer = TiffWriter::new();
549
550        let mut arr = NDArray::new(
551            vec![NDDimension::new(4), NDDimension::new(4)],
552            NDDataType::UInt8,
553        );
554        if let NDDataBuffer::U8(v) = &mut arr.data {
555            for i in 0..16 {
556                v[i] = (i * 10) as u8;
557            }
558        }
559
560        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
561        writer.write_file(&arr).unwrap();
562
563        let read_back = writer.read_file().unwrap();
564        if let (NDDataBuffer::U8(orig), NDDataBuffer::U8(read)) = (&arr.data, &read_back.data) {
565            assert_eq!(orig, read);
566        } else {
567            panic!("data type mismatch on roundtrip");
568        }
569
570        writer.close_file().unwrap();
571        std::fs::remove_file(&path).ok();
572    }
573
574    #[test]
575    fn test_roundtrip_u16() {
576        let path = temp_path("tiff_rt_u16");
577        let mut writer = TiffWriter::new();
578
579        let mut arr = NDArray::new(
580            vec![NDDimension::new(4), NDDimension::new(4)],
581            NDDataType::UInt16,
582        );
583        if let NDDataBuffer::U16(v) = &mut arr.data {
584            for i in 0..16 {
585                v[i] = (i * 1000) as u16;
586            }
587        }
588
589        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
590        writer.write_file(&arr).unwrap();
591
592        let read_back = writer.read_file().unwrap();
593        if let (NDDataBuffer::U16(orig), NDDataBuffer::U16(read)) = (&arr.data, &read_back.data) {
594            assert_eq!(orig, read);
595        } else {
596            panic!("data type mismatch on roundtrip");
597        }
598
599        writer.close_file().unwrap();
600        std::fs::remove_file(&path).ok();
601    }
602
603    #[test]
604    fn test_on_param_change_read_file_emits_array_and_resets_busy() {
605        let path = temp_path("tiff_read_param");
606        let mut writer = TiffWriter::new();
607
608        let mut arr = NDArray::new(
609            vec![NDDimension::new(4), NDDimension::new(3)],
610            NDDataType::UInt8,
611        );
612        arr.unique_id = 77;
613        if let NDDataBuffer::U8(v) = &mut arr.data {
614            for (i, item) in v.iter_mut().enumerate() {
615                *item = i as u8;
616            }
617        }
618
619        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
620        writer.write_file(&arr).unwrap();
621        writer.close_file().unwrap();
622
623        let mut base = PortDriverBase::new("TIFFTEST", 1, PortFlags::default());
624        let _nd_params = NDArrayDriverParams::create(&mut base).unwrap();
625
626        let mut proc = TiffFileProcessor::new();
627        proc.register_params(&mut base).unwrap();
628
629        let reason_path = base.find_param("FILE_PATH").unwrap();
630        let reason_name = base.find_param("FILE_NAME").unwrap();
631        let reason_template = base.find_param("FILE_TEMPLATE").unwrap();
632        let reason_read = base.find_param("READ_FILE").unwrap();
633
634        let _ = proc.on_param_change(
635            reason_path,
636            &PluginParamSnapshot {
637                enable_callbacks: true,
638                reason: reason_path,
639                addr: 0,
640                value: ParamChangeValue::Octet(
641                    path.parent().unwrap().to_str().unwrap().to_string(),
642                ),
643            },
644        );
645        let _ = proc.on_param_change(
646            reason_name,
647            &PluginParamSnapshot {
648                enable_callbacks: true,
649                reason: reason_name,
650                addr: 0,
651                value: ParamChangeValue::Octet(
652                    path.file_name().unwrap().to_str().unwrap().to_string(),
653                ),
654            },
655        );
656        let _ = proc.on_param_change(
657            reason_template,
658            &PluginParamSnapshot {
659                enable_callbacks: true,
660                reason: reason_template,
661                addr: 0,
662                value: ParamChangeValue::Octet("%s%s".into()),
663            },
664        );
665
666        let result = proc.on_param_change(
667            reason_read,
668            &PluginParamSnapshot {
669                enable_callbacks: true,
670                reason: reason_read,
671                addr: 0,
672                value: ParamChangeValue::Int32(1),
673            },
674        );
675
676        assert_eq!(result.output_arrays.len(), 1);
677        assert!(result.param_updates.iter().any(|u| matches!(
678            u,
679            ParamUpdate::Int32 { reason, value: 0, .. } if *reason == reason_read
680        )));
681        match &result.output_arrays[0].data {
682            NDDataBuffer::U8(v) => assert_eq!(v.len(), 12),
683            other => panic!("unexpected data buffer: {other:?}"),
684        }
685
686        std::fs::remove_file(&path).ok();
687    }
688
689    #[test]
690    fn test_metadata_tags_match_cpp_numbers_and_types() {
691        let path = temp_path("tiff_meta_tags");
692        let mut writer = TiffWriter::new();
693
694        let mut arr = NDArray::new(
695            vec![NDDimension::new(4), NDDimension::new(4)],
696            NDDataType::UInt8,
697        );
698        arr.unique_id = 4242;
699        arr.time_stamp = 1234.5;
700        arr.timestamp.sec = 1_000_000;
701        arr.timestamp.nsec = 500;
702
703        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
704        writer.write_file(&arr).unwrap();
705        writer.close_file().unwrap();
706
707        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
708        // 65000 = NDTimeStamp (TIFF_DOUBLE).
709        assert_eq!(decoder.get_tag_f64(Tag::Unknown(65000)).unwrap(), 1234.5);
710        // 65001 = NDUniqueId (TIFF_LONG).
711        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65001)).unwrap(), 4242);
712        // 65002 = EPICSTSSec, 65003 = EPICSTSNsec.
713        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65002)).unwrap(), 1_000_000);
714        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65003)).unwrap(), 500);
715        // Standard Software tag.
716        assert_eq!(
717            decoder
718                .get_tag(Tag::Software)
719                .unwrap()
720                .into_string()
721                .unwrap(),
722            "EPICS areaDetector"
723        );
724
725        std::fs::remove_file(&path).ok();
726    }
727
728    #[test]
729    fn test_standard_tags_from_attributes() {
730        let path = temp_path("tiff_std_tags");
731        let mut writer = TiffWriter::new();
732
733        let mut arr = NDArray::new(
734            vec![NDDimension::new(4), NDDimension::new(4)],
735            NDDataType::UInt8,
736        );
737        arr.attributes.add(NDAttribute::new_static(
738            "Model",
739            "",
740            NDAttrSource::Driver,
741            NDAttrValue::String("SimDetector".into()),
742        ));
743        arr.attributes.add(NDAttribute::new_static(
744            "Manufacturer",
745            "",
746            NDAttrSource::Driver,
747            NDAttrValue::String("EPICS".into()),
748        ));
749        arr.attributes.add(NDAttribute::new_static(
750            "TIFFImageDescription",
751            "",
752            NDAttrSource::Driver,
753            NDAttrValue::String("test frame".into()),
754        ));
755
756        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
757        writer.write_file(&arr).unwrap();
758        writer.close_file().unwrap();
759
760        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
761        assert_eq!(
762            decoder.get_tag(Tag::Model).unwrap().into_string().unwrap(),
763            "SimDetector"
764        );
765        assert_eq!(
766            decoder.get_tag(Tag::Make).unwrap().into_string().unwrap(),
767            "EPICS"
768        );
769        assert_eq!(
770            decoder
771                .get_tag(Tag::ImageDescription)
772                .unwrap()
773                .into_string()
774                .unwrap(),
775            "test frame"
776        );
777
778        std::fs::remove_file(&path).ok();
779    }
780
781    #[test]
782    fn test_attribute_tag_format_uses_colon_and_type() {
783        // C++ uses "name:value" with typed numeric formatting.
784        let mut a = NDArray::new(
785            vec![NDDimension::new(2), NDDimension::new(2)],
786            NDDataType::UInt8,
787        );
788        a.attributes.add(NDAttribute::new_static(
789            "Gain",
790            "",
791            NDAttrSource::Driver,
792            NDAttrValue::Int32(-7),
793        ));
794
795        let path = temp_path("tiff_attr_fmt");
796        let mut writer = TiffWriter::new();
797        writer.open_file(&path, NDFileMode::Single, &a).unwrap();
798        writer.write_file(&a).unwrap();
799        writer.close_file().unwrap();
800
801        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
802        // First attribute tag is 65010.
803        let s = decoder
804            .get_tag(Tag::Unknown(65010))
805            .unwrap()
806            .into_string()
807            .unwrap();
808        assert_eq!(s, "Gain:-7");
809
810        std::fs::remove_file(&path).ok();
811    }
812
813    #[test]
814    fn test_signed_rgb_writes_instead_of_erroring() {
815        let path = temp_path("tiff_signed_rgb");
816        let mut writer = TiffWriter::new();
817
818        let mut arr = NDArray::new(
819            vec![
820                NDDimension::new(3),
821                NDDimension::new(2),
822                NDDimension::new(2),
823            ],
824            NDDataType::Int16,
825        );
826        TiffWriter::attach_color_mode(&mut arr, NDColorMode::RGB1);
827        if let NDDataBuffer::I16(v) = &mut arr.data {
828            for (i, item) in v.iter_mut().enumerate() {
829                *item = (i as i16) - 6;
830            }
831        }
832
833        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
834        // Previously a hard error; must now succeed.
835        writer.write_file(&arr).unwrap();
836        writer.close_file().unwrap();
837
838        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
839        let sf = decoder.get_tag_u16_vec(Tag::SampleFormat).unwrap();
840        // SampleFormat 2 = SAMPLEFORMAT_INT.
841        assert!(sf.iter().all(|&s| s == 2), "expected signed sample format");
842
843        std::fs::remove_file(&path).ok();
844    }
845
846    #[test]
847    fn test_single_mode_requires_auto_save_for_automatic_write() {
848        let path = temp_path("tiff_autosave_single");
849        let full_name = path.to_string_lossy().to_string();
850        let file_path = path.parent().unwrap().to_str().unwrap().to_string();
851        let file_name = path.file_name().unwrap().to_str().unwrap().to_string();
852
853        let mut proc = TiffFileProcessor::new();
854        proc.ctrl.file_base.file_path = file_path.clone() + "/";
855        proc.ctrl.file_base.file_name = file_name;
856        proc.ctrl.file_base.file_template = "%s%s".into();
857        proc.ctrl.file_base.set_mode(NDFileMode::Single);
858
859        let mut arr = NDArray::new(
860            vec![NDDimension::new(4), NDDimension::new(4)],
861            NDDataType::UInt8,
862        );
863        if let NDDataBuffer::U8(v) = &mut arr.data {
864            for (i, item) in v.iter_mut().enumerate() {
865                *item = i as u8;
866            }
867        }
868
869        proc.ctrl.auto_save = false;
870        let _ = proc.process_array(&arr, &NDArrayPool::new(1024));
871        assert!(!std::path::Path::new(&full_name).exists());
872
873        proc.ctrl.auto_save = true;
874        let _ = proc.process_array(&arr, &NDArrayPool::new(1024));
875        assert!(std::path::Path::new(&full_name).exists());
876
877        std::fs::remove_file(&path).ok();
878    }
879}