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    fn register_params(
459        &mut self,
460        base: &mut asyn_rs::port::PortDriverBase,
461    ) -> asyn_rs::error::AsynResult<()> {
462        self.ctrl.register_params(base)
463    }
464
465    fn on_param_change(
466        &mut self,
467        reason: usize,
468        params: &PluginParamSnapshot,
469    ) -> ParamChangeResult {
470        self.ctrl.on_param_change(reason, params)
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use ad_core_rs::ndarray::NDDataBuffer;
478    use ad_core_rs::params::ndarray_driver::NDArrayDriverParams;
479    use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
480    use asyn_rs::port::{PortDriverBase, PortFlags};
481    use std::sync::atomic::{AtomicU32, Ordering};
482
483    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
484
485    fn temp_path(prefix: &str) -> PathBuf {
486        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
487        std::env::temp_dir().join(format!("adcore_test_{}_{}.tif", prefix, n))
488    }
489
490    #[test]
491    fn test_write_u8_mono() {
492        let path = temp_path("tiff_u8");
493        let mut writer = TiffWriter::new();
494
495        let mut arr = NDArray::new(
496            vec![NDDimension::new(4), NDDimension::new(4)],
497            NDDataType::UInt8,
498        );
499        if let NDDataBuffer::U8(v) = &mut arr.data {
500            for i in 0..16 {
501                v[i] = i as u8;
502            }
503        }
504
505        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
506        writer.write_file(&arr).unwrap();
507        writer.close_file().unwrap();
508
509        let data = std::fs::read(&path).unwrap();
510        assert!(data.len() > 16);
511        assert!(
512            &data[0..2] == &[0x49, 0x49] || &data[0..2] == &[0x4D, 0x4D],
513            "Expected TIFF magic bytes"
514        );
515
516        std::fs::remove_file(&path).ok();
517    }
518
519    #[test]
520    fn test_write_u16() {
521        let path = temp_path("tiff_u16");
522        let mut writer = TiffWriter::new();
523
524        let arr = NDArray::new(
525            vec![NDDimension::new(4), NDDimension::new(4)],
526            NDDataType::UInt16,
527        );
528
529        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
530        writer.write_file(&arr).unwrap();
531        writer.close_file().unwrap();
532
533        let data = std::fs::read(&path).unwrap();
534        assert!(data.len() > 32);
535
536        std::fs::remove_file(&path).ok();
537    }
538
539    #[test]
540    fn test_roundtrip_u8() {
541        let path = temp_path("tiff_rt_u8");
542        let mut writer = TiffWriter::new();
543
544        let mut arr = NDArray::new(
545            vec![NDDimension::new(4), NDDimension::new(4)],
546            NDDataType::UInt8,
547        );
548        if let NDDataBuffer::U8(v) = &mut arr.data {
549            for i in 0..16 {
550                v[i] = (i * 10) as u8;
551            }
552        }
553
554        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
555        writer.write_file(&arr).unwrap();
556
557        let read_back = writer.read_file().unwrap();
558        if let (NDDataBuffer::U8(orig), NDDataBuffer::U8(read)) = (&arr.data, &read_back.data) {
559            assert_eq!(orig, read);
560        } else {
561            panic!("data type mismatch on roundtrip");
562        }
563
564        writer.close_file().unwrap();
565        std::fs::remove_file(&path).ok();
566    }
567
568    #[test]
569    fn test_roundtrip_u16() {
570        let path = temp_path("tiff_rt_u16");
571        let mut writer = TiffWriter::new();
572
573        let mut arr = NDArray::new(
574            vec![NDDimension::new(4), NDDimension::new(4)],
575            NDDataType::UInt16,
576        );
577        if let NDDataBuffer::U16(v) = &mut arr.data {
578            for i in 0..16 {
579                v[i] = (i * 1000) as u16;
580            }
581        }
582
583        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
584        writer.write_file(&arr).unwrap();
585
586        let read_back = writer.read_file().unwrap();
587        if let (NDDataBuffer::U16(orig), NDDataBuffer::U16(read)) = (&arr.data, &read_back.data) {
588            assert_eq!(orig, read);
589        } else {
590            panic!("data type mismatch on roundtrip");
591        }
592
593        writer.close_file().unwrap();
594        std::fs::remove_file(&path).ok();
595    }
596
597    #[test]
598    fn test_on_param_change_read_file_emits_array_and_resets_busy() {
599        let path = temp_path("tiff_read_param");
600        let mut writer = TiffWriter::new();
601
602        let mut arr = NDArray::new(
603            vec![NDDimension::new(4), NDDimension::new(3)],
604            NDDataType::UInt8,
605        );
606        arr.unique_id = 77;
607        if let NDDataBuffer::U8(v) = &mut arr.data {
608            for (i, item) in v.iter_mut().enumerate() {
609                *item = i as u8;
610            }
611        }
612
613        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
614        writer.write_file(&arr).unwrap();
615        writer.close_file().unwrap();
616
617        let mut base = PortDriverBase::new("TIFFTEST", 1, PortFlags::default());
618        let _nd_params = NDArrayDriverParams::create(&mut base).unwrap();
619
620        let mut proc = TiffFileProcessor::new();
621        proc.register_params(&mut base).unwrap();
622
623        let reason_path = base.find_param("FILE_PATH").unwrap();
624        let reason_name = base.find_param("FILE_NAME").unwrap();
625        let reason_template = base.find_param("FILE_TEMPLATE").unwrap();
626        let reason_read = base.find_param("READ_FILE").unwrap();
627
628        let _ = proc.on_param_change(
629            reason_path,
630            &PluginParamSnapshot {
631                enable_callbacks: true,
632                reason: reason_path,
633                addr: 0,
634                value: ParamChangeValue::Octet(
635                    path.parent().unwrap().to_str().unwrap().to_string(),
636                ),
637            },
638        );
639        let _ = proc.on_param_change(
640            reason_name,
641            &PluginParamSnapshot {
642                enable_callbacks: true,
643                reason: reason_name,
644                addr: 0,
645                value: ParamChangeValue::Octet(
646                    path.file_name().unwrap().to_str().unwrap().to_string(),
647                ),
648            },
649        );
650        let _ = proc.on_param_change(
651            reason_template,
652            &PluginParamSnapshot {
653                enable_callbacks: true,
654                reason: reason_template,
655                addr: 0,
656                value: ParamChangeValue::Octet("%s%s".into()),
657            },
658        );
659
660        let result = proc.on_param_change(
661            reason_read,
662            &PluginParamSnapshot {
663                enable_callbacks: true,
664                reason: reason_read,
665                addr: 0,
666                value: ParamChangeValue::Int32(1),
667            },
668        );
669
670        assert_eq!(result.output_arrays.len(), 1);
671        assert!(result.param_updates.iter().any(|u| matches!(
672            u,
673            ParamUpdate::Int32 { reason, value: 0, .. } if *reason == reason_read
674        )));
675        match &result.output_arrays[0].data {
676            NDDataBuffer::U8(v) => assert_eq!(v.len(), 12),
677            other => panic!("unexpected data buffer: {other:?}"),
678        }
679
680        std::fs::remove_file(&path).ok();
681    }
682
683    #[test]
684    fn test_metadata_tags_match_cpp_numbers_and_types() {
685        let path = temp_path("tiff_meta_tags");
686        let mut writer = TiffWriter::new();
687
688        let mut arr = NDArray::new(
689            vec![NDDimension::new(4), NDDimension::new(4)],
690            NDDataType::UInt8,
691        );
692        arr.unique_id = 4242;
693        arr.time_stamp = 1234.5;
694        arr.timestamp.sec = 1_000_000;
695        arr.timestamp.nsec = 500;
696
697        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
698        writer.write_file(&arr).unwrap();
699        writer.close_file().unwrap();
700
701        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
702        // 65000 = NDTimeStamp (TIFF_DOUBLE).
703        assert_eq!(decoder.get_tag_f64(Tag::Unknown(65000)).unwrap(), 1234.5);
704        // 65001 = NDUniqueId (TIFF_LONG).
705        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65001)).unwrap(), 4242);
706        // 65002 = EPICSTSSec, 65003 = EPICSTSNsec.
707        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65002)).unwrap(), 1_000_000);
708        assert_eq!(decoder.get_tag_u32(Tag::Unknown(65003)).unwrap(), 500);
709        // Standard Software tag.
710        assert_eq!(
711            decoder
712                .get_tag(Tag::Software)
713                .unwrap()
714                .into_string()
715                .unwrap(),
716            "EPICS areaDetector"
717        );
718
719        std::fs::remove_file(&path).ok();
720    }
721
722    #[test]
723    fn test_standard_tags_from_attributes() {
724        let path = temp_path("tiff_std_tags");
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        arr.attributes.add(NDAttribute::new_static(
732            "Model",
733            "",
734            NDAttrSource::Driver,
735            NDAttrValue::String("SimDetector".into()),
736        ));
737        arr.attributes.add(NDAttribute::new_static(
738            "Manufacturer",
739            "",
740            NDAttrSource::Driver,
741            NDAttrValue::String("EPICS".into()),
742        ));
743        arr.attributes.add(NDAttribute::new_static(
744            "TIFFImageDescription",
745            "",
746            NDAttrSource::Driver,
747            NDAttrValue::String("test frame".into()),
748        ));
749
750        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
751        writer.write_file(&arr).unwrap();
752        writer.close_file().unwrap();
753
754        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
755        assert_eq!(
756            decoder.get_tag(Tag::Model).unwrap().into_string().unwrap(),
757            "SimDetector"
758        );
759        assert_eq!(
760            decoder.get_tag(Tag::Make).unwrap().into_string().unwrap(),
761            "EPICS"
762        );
763        assert_eq!(
764            decoder
765                .get_tag(Tag::ImageDescription)
766                .unwrap()
767                .into_string()
768                .unwrap(),
769            "test frame"
770        );
771
772        std::fs::remove_file(&path).ok();
773    }
774
775    #[test]
776    fn test_attribute_tag_format_uses_colon_and_type() {
777        // C++ uses "name:value" with typed numeric formatting.
778        let mut a = NDArray::new(
779            vec![NDDimension::new(2), NDDimension::new(2)],
780            NDDataType::UInt8,
781        );
782        a.attributes.add(NDAttribute::new_static(
783            "Gain",
784            "",
785            NDAttrSource::Driver,
786            NDAttrValue::Int32(-7),
787        ));
788
789        let path = temp_path("tiff_attr_fmt");
790        let mut writer = TiffWriter::new();
791        writer.open_file(&path, NDFileMode::Single, &a).unwrap();
792        writer.write_file(&a).unwrap();
793        writer.close_file().unwrap();
794
795        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
796        // First attribute tag is 65010.
797        let s = decoder
798            .get_tag(Tag::Unknown(65010))
799            .unwrap()
800            .into_string()
801            .unwrap();
802        assert_eq!(s, "Gain:-7");
803
804        std::fs::remove_file(&path).ok();
805    }
806
807    #[test]
808    fn test_signed_rgb_writes_instead_of_erroring() {
809        let path = temp_path("tiff_signed_rgb");
810        let mut writer = TiffWriter::new();
811
812        let mut arr = NDArray::new(
813            vec![
814                NDDimension::new(3),
815                NDDimension::new(2),
816                NDDimension::new(2),
817            ],
818            NDDataType::Int16,
819        );
820        TiffWriter::attach_color_mode(&mut arr, NDColorMode::RGB1);
821        if let NDDataBuffer::I16(v) = &mut arr.data {
822            for (i, item) in v.iter_mut().enumerate() {
823                *item = (i as i16) - 6;
824            }
825        }
826
827        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
828        // Previously a hard error; must now succeed.
829        writer.write_file(&arr).unwrap();
830        writer.close_file().unwrap();
831
832        let mut decoder = Decoder::new(std::fs::File::open(&path).unwrap()).unwrap();
833        let sf = decoder.get_tag_u16_vec(Tag::SampleFormat).unwrap();
834        // SampleFormat 2 = SAMPLEFORMAT_INT.
835        assert!(sf.iter().all(|&s| s == 2), "expected signed sample format");
836
837        std::fs::remove_file(&path).ok();
838    }
839
840    #[test]
841    fn test_single_mode_requires_auto_save_for_automatic_write() {
842        let path = temp_path("tiff_autosave_single");
843        let full_name = path.to_string_lossy().to_string();
844        let file_path = path.parent().unwrap().to_str().unwrap().to_string();
845        let file_name = path.file_name().unwrap().to_str().unwrap().to_string();
846
847        let mut proc = TiffFileProcessor::new();
848        proc.ctrl.file_base.file_path = file_path.clone() + "/";
849        proc.ctrl.file_base.file_name = file_name;
850        proc.ctrl.file_base.file_template = "%s%s".into();
851        proc.ctrl.file_base.set_mode(NDFileMode::Single);
852
853        let mut arr = NDArray::new(
854            vec![NDDimension::new(4), NDDimension::new(4)],
855            NDDataType::UInt8,
856        );
857        if let NDDataBuffer::U8(v) = &mut arr.data {
858            for (i, item) in v.iter_mut().enumerate() {
859                *item = i as u8;
860            }
861        }
862
863        proc.ctrl.auto_save = false;
864        let _ = proc.process_array(&arr, &NDArrayPool::new(1024));
865        assert!(!std::path::Path::new(&full_name).exists());
866
867        proc.ctrl.auto_save = true;
868        let _ = proc.process_array(&arr, &NDArrayPool::new(1024));
869        assert!(std::path::Path::new(&full_name).exists());
870
871        std::fs::remove_file(&path).ok();
872    }
873}