Skip to main content

runmat_runtime/builtins/image/
imwrite.rs

1use runmat_value::IntValue;
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use image::codecs::gif::{GifDecoder, GifEncoder, Repeat};
7use image::{AnimationDecoder, Delay, DynamicImage, Frame, ImageFormat, ImageOutputFormat};
8use image::{ImageBuffer, Luma, Rgb, Rgba, RgbaImage};
9use runmat_builtins::{
10    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
11    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
12    BuiltinIntegerClass, BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
13    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
14    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
15    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
16};
17use runmat_macros::runtime_builtin;
18use runmat_value::{IntegerStorage, LogicalArray, NumericDType, NumericScalar, Tensor, Value};
19
20use crate::builtins::common::spec::{
21    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
22    ReductionNaN, ResidencyPolicy, ShapeRequirements,
23};
24use crate::builtins::common::tensor;
25use crate::builtins::image::type_resolvers::imwrite_type;
26use crate::{build_runtime_error, BuiltinResult, RuntimeError};
27
28const BUILTIN_NAME: &str = "imwrite";
29
30const IMWRITE_INPUTS_IMAGE_FILENAME: [BuiltinParamDescriptor; 2] = [
31    BuiltinParamDescriptor {
32        name: "A",
33        ty: BuiltinParamType::NumericArray,
34        arity: BuiltinParamArity::Required,
35        default: None,
36        description: "Grayscale, truecolor, or RGBA image data.",
37    },
38    BuiltinParamDescriptor {
39        name: "filename",
40        ty: BuiltinParamType::StringScalar,
41        arity: BuiltinParamArity::Required,
42        default: None,
43        description: "Output image path.",
44    },
45];
46
47const IMWRITE_INPUTS_INDEXED: [BuiltinParamDescriptor; 3] = [
48    BuiltinParamDescriptor {
49        name: "X",
50        ty: BuiltinParamType::NumericArray,
51        arity: BuiltinParamArity::Required,
52        default: None,
53        description: "Indexed image data.",
54    },
55    BuiltinParamDescriptor {
56        name: "map",
57        ty: BuiltinParamType::NumericArray,
58        arity: BuiltinParamArity::Required,
59        default: None,
60        description: "Nx3 colormap.",
61    },
62    BuiltinParamDescriptor {
63        name: "filename",
64        ty: BuiltinParamType::StringScalar,
65        arity: BuiltinParamArity::Required,
66        default: None,
67        description: "Output image path.",
68    },
69];
70
71const IMWRITE_INPUTS_OPTIONS: [BuiltinParamDescriptor; 4] = [
72    BuiltinParamDescriptor {
73        name: "A",
74        ty: BuiltinParamType::NumericArray,
75        arity: BuiltinParamArity::Required,
76        default: None,
77        description: "Image data.",
78    },
79    BuiltinParamDescriptor {
80        name: "filename",
81        ty: BuiltinParamType::StringScalar,
82        arity: BuiltinParamArity::Required,
83        default: None,
84        description: "Output image path.",
85    },
86    BuiltinParamDescriptor {
87        name: "name",
88        ty: BuiltinParamType::StringScalar,
89        arity: BuiltinParamArity::Variadic,
90        default: None,
91        description: "Name-value option.",
92    },
93    BuiltinParamDescriptor {
94        name: "value",
95        ty: BuiltinParamType::Any,
96        arity: BuiltinParamArity::Variadic,
97        default: None,
98        description: "Name-value option value.",
99    },
100];
101
102const IMWRITE_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
103    BuiltinSignatureDescriptor {
104        label: "imwrite(A, filename)",
105        inputs: &IMWRITE_INPUTS_IMAGE_FILENAME,
106        outputs: &[],
107    },
108    BuiltinSignatureDescriptor {
109        label: "imwrite(A, filename, fmt)",
110        inputs: &IMWRITE_INPUTS_OPTIONS,
111        outputs: &[],
112    },
113    BuiltinSignatureDescriptor {
114        label: "imwrite(A, filename, name, value, ...)",
115        inputs: &IMWRITE_INPUTS_OPTIONS,
116        outputs: &[],
117    },
118    BuiltinSignatureDescriptor {
119        label: "imwrite(X, map, filename, ...)",
120        inputs: &IMWRITE_INPUTS_INDEXED,
121        outputs: &[],
122    },
123];
124
125const IMWRITE_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
126    code: "RM.IMWRITE.INVALID_ARGUMENT",
127    identifier: Some("RunMat:imwrite:InvalidArgument"),
128    when: "Arguments do not match a supported imwrite form.",
129    message: "imwrite: invalid argument",
130};
131const IMWRITE_ERROR_INVALID_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
132    code: "RM.IMWRITE.INVALID_FILENAME",
133    identifier: Some("RunMat:imwrite:InvalidFilename"),
134    when: "Filename is missing or empty.",
135    message: "imwrite: invalid filename",
136};
137const IMWRITE_ERROR_INVALID_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
138    code: "RM.IMWRITE.INVALID_FORMAT",
139    identifier: Some("RunMat:imwrite:InvalidFormat"),
140    when: "Image format cannot be inferred or is unsupported.",
141    message: "imwrite: invalid image format",
142};
143const IMWRITE_ERROR_INVALID_IMAGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
144    code: "RM.IMWRITE.INVALID_IMAGE",
145    identifier: Some("RunMat:imwrite:InvalidImage"),
146    when: "Image data has unsupported type, shape, or values.",
147    message: "imwrite: invalid image data",
148};
149const IMWRITE_ERROR_INVALID_COLORMAP: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
150    code: "RM.IMWRITE.INVALID_COLORMAP",
151    identifier: Some("RunMat:imwrite:InvalidColormap"),
152    when: "Indexed-image colormap is not an Nx3 numeric array.",
153    message: "imwrite: invalid colormap",
154};
155const IMWRITE_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
156    code: "RM.IMWRITE.INVALID_OPTION",
157    identifier: Some("RunMat:imwrite:InvalidOption"),
158    when: "Name-value option is malformed or unsupported for the requested format.",
159    message: "imwrite: invalid option",
160};
161const IMWRITE_ERROR_ENCODE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
162    code: "RM.IMWRITE.ENCODE",
163    identifier: Some("RunMat:imwrite:EncodeError"),
164    when: "Image data cannot be encoded.",
165    message: "imwrite: encode error",
166};
167const IMWRITE_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
168    code: "RM.IMWRITE.IO",
169    identifier: Some("RunMat:imwrite:Io"),
170    when: "Image file cannot be read for append or written.",
171    message: "imwrite: file I/O error",
172};
173
174const IMWRITE_ERRORS: [BuiltinErrorDescriptor; 8] = [
175    IMWRITE_ERROR_INVALID_ARGUMENT,
176    IMWRITE_ERROR_INVALID_FILENAME,
177    IMWRITE_ERROR_INVALID_FORMAT,
178    IMWRITE_ERROR_INVALID_IMAGE,
179    IMWRITE_ERROR_INVALID_COLORMAP,
180    IMWRITE_ERROR_INVALID_OPTION,
181    IMWRITE_ERROR_ENCODE,
182    IMWRITE_ERROR_IO,
183];
184
185pub const IMWRITE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
186    signatures: &IMWRITE_SIGNATURES,
187    output_mode: BuiltinOutputMode::Fixed,
188    completion_policy: BuiltinCompletionPolicy::Public,
189    errors: &IMWRITE_ERRORS,
190};
191
192const IMWRITE_SINGLE_GIF_TIFF_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
193    id: "imwrite-single-gif-tiff",
194    mode: BuiltinExtensionMode::RunMatOnly,
195    description: "imwrite accepts direct single image data for GIF and TIFF as a RunMat extension",
196    error_identifier: Some("RunMat:compatibility:ImwriteSingleGifTiffExtension"),
197};
198pub const IMWRITE_EXTENSIONS: [BuiltinExtensionDescriptor; 1] = [IMWRITE_SINGLE_GIF_TIFF_EXTENSION];
199
200const IMWRITE_DOCUMENTED_INTEGER_CLASSES: [BuiltinIntegerClass; 2] =
201    [BuiltinIntegerClass::Uint8, BuiltinIntegerClass::Uint16];
202const IMWRITE_REJECTED_INTEGER_CLASSES: [BuiltinIntegerClass; 6] = [
203    BuiltinIntegerClass::Int8,
204    BuiltinIntegerClass::Int16,
205    BuiltinIntegerClass::Int32,
206    BuiltinIntegerClass::Int64,
207    BuiltinIntegerClass::Uint32,
208    BuiltinIntegerClass::Uint64,
209];
210const IMWRITE_DOCUMENTED_INTEGER_INPUT: [BuiltinIntegerInputCapability; 1] =
211    [BuiltinIntegerInputCapability { name: "A_or_X", classes: &IMWRITE_DOCUMENTED_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable, notes: "Uint8 and uint16 direct or indexed images retain their native sample/index interpretation through the host encoder sink." }];
212const IMWRITE_REJECTED_INTEGER_INPUT: [BuiltinIntegerInputCapability; 1] =
213    [BuiltinIntegerInputCapability { name: "A_or_X", classes: &IMWRITE_REJECTED_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Rejected, scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable, notes: "Signed integer, uint32, and uint64 image arrays are outside the documented imwrite image-data surface and reject before file effects." }];
214const IMWRITE_ALPHA_INTEGER_CLASSES: [BuiltinIntegerClass; 2] =
215    [BuiltinIntegerClass::Uint8, BuiltinIntegerClass::Uint16];
216const IMWRITE_ALPHA_INTEGER_INPUT: [BuiltinIntegerInputCapability; 1] =
217    [BuiltinIntegerInputCapability { name: "Alpha", classes: &IMWRITE_ALPHA_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable, notes: "Documented uint8 and uint16 Alpha matrices are read from authoritative storage and scaled by their own full class range into the encoded channel." }];
218const IMWRITE_CONTROL_INTEGER_INPUT: [BuiltinIntegerInputCapability; 1] =
219    [BuiltinIntegerInputCapability { name: "integer-valued format control", classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable, notes: "RowsPerStrip is documented for every integer class. Other integer-valued controls are format-specific; implemented controls read exact scalar storage before validation." }];
220pub const IMWRITE_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 4] = [
221    BuiltinIntegerCapabilityDescriptor { form: "imwrite(integer_A_or_X, ...)", inputs: &IMWRITE_DOCUMENTED_INTEGER_INPUT, computation_domain: BuiltinIntegerComputationDomain::ExactInteger, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::Multiple, notes: "The documented host/file sink retains native uint8 or uint16 samples and indexed values through encoding. Resident image input is gathered non-destructively through its exact owner, and all validation/encoding completes before the write begins. Encoder limitations such as 16-bit JPEG and TIFF CMYK reject explicitly without changing the class contract or creating a file." },
222    BuiltinIntegerCapabilityDescriptor { form: "imwrite(unsupported_integer_A_or_X, ...)", inputs: &IMWRITE_REJECTED_INTEGER_INPUT, computation_domain: BuiltinIntegerComputationDomain::FunctionSpecific, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::HostAndGpu, overload: BuiltinIntegerOverloadKind::Multiple, notes: "Unsupported image classes reject from authoritative host or resident metadata without a floating compatibility conversion or file effect." },
223    BuiltinIntegerCapabilityDescriptor { form: "imwrite(A, ..., Alpha=integer_alpha)", inputs: &IMWRITE_ALPHA_INTEGER_INPUT, computation_domain: BuiltinIntegerComputationDomain::ExactInteger, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::SameSizeOrScalar, notes: "Documented uint8 and uint16 Alpha arrays preserve exact native samples until deterministic class-range scaling into the image bit depth; shape and format validation complete before file effects." },
224    BuiltinIntegerCapabilityDescriptor { form: "imwrite(A, ..., integer_control)", inputs: &IMWRITE_CONTROL_INTEGER_INPUT, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::ScalarOnly, notes: "Typed scalar controls are read exactly and range-checked before encoding. Documented but not-yet-supported format controls, including RowsPerStrip and explicit BitDepth, reject with the stable invalid-option error before any file effect rather than silently coercing or ignoring the value." },
225];
226
227#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::image::imwrite")]
228pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
229    name: "imwrite",
230    op_kind: GpuOpKind::Custom("image-imwrite"),
231    supported_precisions: &[],
232    broadcast: BroadcastSemantics::None,
233    provider_hooks: &[],
234    constant_strategy: ConstantStrategy::InlineLiteral,
235    residency: ResidencyPolicy::GatherImmediately,
236    nan_mode: ReductionNaN::Include,
237    two_pass_threshold: None,
238    workgroup_size: None,
239    accepts_nan_mode: false,
240    notes: "Host image encoder sink; gpuArray inputs are gathered before writing.",
241};
242
243#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::image::imwrite")]
244pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
245    name: "imwrite",
246    shape: ShapeRequirements::Any,
247    constant_strategy: ConstantStrategy::InlineLiteral,
248    elementwise: None,
249    reduction: None,
250    emits_nan: false,
251    notes: "File I/O is not eligible for fusion.",
252};
253
254#[runtime_builtin(
255    name = "imwrite",
256    category = "image/io",
257    summary = "Write image data to a file.",
258    keywords = "image,write,imwrite,png,jpeg,gif,bmp,tiff",
259    sink = true,
260    suppress_auto_output = true,
261    type_resolver(imwrite_type),
262    descriptor(crate::builtins::image::imwrite::IMWRITE_DESCRIPTOR),
263    extensions(crate::builtins::image::imwrite::IMWRITE_EXTENSIONS),
264    integer_capabilities(crate::builtins::image::imwrite::IMWRITE_INTEGER_CAPABILITIES),
265    builtin_path = "crate::builtins::image::imwrite"
266)]
267async fn imwrite_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
268    if let Some(n) = crate::output_count::current_output_count() {
269        if n > 0 {
270            return Err(imwrite_error_with_detail(
271                &IMWRITE_ERROR_INVALID_ARGUMENT,
272                "imwrite does not return output arguments",
273            ));
274        }
275    }
276
277    preflight_resident_argument_roles(&args)?;
278    let mut host_args = Vec::with_capacity(args.len());
279    for arg in &args {
280        host_args.push(gather_imwrite_argument(arg).await?);
281    }
282
283    let invocation = parse_invocation(&host_args)?;
284    if matches!(invocation.format, ImageFormat::Gif | ImageFormat::Tiff)
285        && value_numeric_dtype(&invocation.image) == Some(NumericDType::F32)
286    {
287        crate::compatibility::ensure_builtin_extension_enabled(
288            &IMWRITE_SINGLE_GIF_TIFF_EXTENSION,
289            BUILTIN_NAME,
290        )?;
291    }
292    let image = materialize_image(
293        &invocation.image,
294        invocation.map.as_ref(),
295        invocation.alpha.as_ref(),
296    )?;
297    let bytes = encode_image(&image, &invocation).await?;
298    runmat_filesystem::write_async(&invocation.path, &bytes)
299        .await
300        .map_err(|err| {
301            imwrite_error_with_detail(
302                &IMWRITE_ERROR_IO,
303                format!("failed to write '{}': {err}", invocation.path.display()),
304            )
305        })?;
306
307    Ok(Value::OutputList(Vec::new()))
308}
309
310fn preflight_resident_argument_roles(args: &[Value]) -> BuiltinResult<()> {
311    for value in args {
312        if let Value::GpuTensor(handle) = value {
313            validate_resident_numeric_metadata(handle, &IMWRITE_ERROR_INVALID_ARGUMENT)?;
314        }
315    }
316    let Some(image) = args.first() else {
317        return Ok(());
318    };
319    preflight_resident_image_class(image)?;
320    if args.get(1).is_some_and(|value| !is_string_like(value)) {
321        if let Some(Value::GpuTensor(map)) = args.get(1) {
322            let owner = validate_resident_numeric_metadata(map, &IMWRITE_ERROR_INVALID_COLORMAP)?;
323            if runmat_accelerate_api::handle_integer_type(map).is_some()
324                || runmat_accelerate_api::handle_is_logical(map)
325                || runmat_accelerate_api::handle_precision(map)
326                    != Some(runmat_accelerate_api::ProviderPrecision::F64)
327                || owner.precision() != runmat_accelerate_api::ProviderPrecision::F64
328            {
329                return Err(imwrite_error_with_detail(
330                    &IMWRITE_ERROR_INVALID_COLORMAP,
331                    "map must be a double Nx3 colormap",
332                ));
333            }
334        }
335    }
336    Ok(())
337}
338
339fn validate_resident_numeric_metadata(
340    handle: &runmat_accelerate_api::GpuTensorHandle,
341    error: &'static BuiltinErrorDescriptor,
342) -> BuiltinResult<&'static dyn runmat_accelerate_api::AccelProvider> {
343    let owner = crate::builtins::common::gpu_helpers::exact_provider_for_handle(handle)
344        .ok_or_else(|| {
345            imwrite_error_with_detail(error, "no acceleration provider owns the gpuArray handle")
346        })?;
347    if runmat_accelerate_api::handle_storage(handle)
348        != runmat_accelerate_api::GpuTensorStorage::Real
349    {
350        return Err(imwrite_error_with_detail(
351            error,
352            "gpuArray arguments must use real numeric storage",
353        ));
354    }
355    let precision = runmat_accelerate_api::handle_precision(handle);
356    let integer = runmat_accelerate_api::handle_integer_type(handle);
357    let logical = runmat_accelerate_api::handle_is_logical(handle);
358    if !crate::builtins::common::gpu_helpers::gpu_class_metadata_matches(
359        handle, precision, integer, logical,
360    ) {
361        return Err(imwrite_error_with_detail(
362            error,
363            "gpuArray class metadata contradicts its physical storage",
364        ));
365    }
366    if integer.is_none() && precision != Some(owner.precision()) {
367        return Err(imwrite_error_with_detail(
368            error,
369            "gpuArray precision metadata contradicts its owning provider",
370        ));
371    }
372    Ok(owner)
373}
374
375async fn gather_imwrite_argument(value: &Value) -> BuiltinResult<Value> {
376    let Value::GpuTensor(handle) = value else {
377        return Ok(value.clone());
378    };
379    let owner = validate_resident_numeric_metadata(handle, &IMWRITE_ERROR_INVALID_ARGUMENT)?;
380    let metadata = crate::builtins::common::gpu_helpers::snapshot_handle_metadata(handle);
381    let result = crate::builtins::common::gpu_helpers::download_value_preserving_residency_async(
382        owner, handle,
383    )
384    .await;
385    crate::builtins::common::gpu_helpers::restore_handle_metadata(handle, &metadata);
386    result.map_err(|err| imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_ARGUMENT, err.message()))
387}
388
389fn preflight_resident_image_class(value: &Value) -> BuiltinResult<()> {
390    let Value::GpuTensor(handle) = value else {
391        return Ok(());
392    };
393    validate_resident_numeric_metadata(handle, &IMWRITE_ERROR_INVALID_IMAGE)?;
394    if matches!(
395        runmat_accelerate_api::handle_integer_type(handle),
396        Some(
397            runmat_accelerate_api::IntegerElementType::I8
398                | runmat_accelerate_api::IntegerElementType::I16
399                | runmat_accelerate_api::IntegerElementType::I32
400                | runmat_accelerate_api::IntegerElementType::I64
401                | runmat_accelerate_api::IntegerElementType::U32
402                | runmat_accelerate_api::IntegerElementType::U64
403        )
404    ) {
405        return Err(imwrite_error_with_detail(
406            &IMWRITE_ERROR_INVALID_IMAGE,
407            "supported integer image classes are uint8 and uint16",
408        ));
409    }
410    Ok(())
411}
412
413fn value_numeric_dtype(value: &Value) -> Option<NumericDType> {
414    match value {
415        Value::Num(_) => Some(NumericDType::F64),
416        Value::Tensor(tensor) => Some(tensor.numeric_dtype()),
417        _ => None,
418    }
419}
420
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
422enum WriteMode {
423    Overwrite,
424    Append,
425}
426
427#[derive(Debug)]
428struct ImwriteOptions {
429    quality: u8,
430    delay_time: Option<f64>,
431    loop_count: Option<f64>,
432    write_mode: WriteMode,
433}
434
435impl Default for ImwriteOptions {
436    fn default() -> Self {
437        Self {
438            quality: 75,
439            delay_time: None,
440            loop_count: None,
441            write_mode: WriteMode::Overwrite,
442        }
443    }
444}
445
446#[derive(Debug)]
447struct Invocation {
448    image: Value,
449    map: Option<Value>,
450    alpha: Option<Tensor>,
451    path: PathBuf,
452    format: ImageFormat,
453    options: ImwriteOptions,
454}
455
456#[derive(Clone)]
457struct MaterializedImage {
458    rows: usize,
459    cols: usize,
460    channels: usize,
461    data: PixelData,
462    alpha_applied: bool,
463    indexed_source: bool,
464}
465
466#[derive(Clone)]
467enum PixelData {
468    U8(Vec<u8>),
469    U16(Vec<u16>),
470}
471
472fn parse_invocation(args: &[Value]) -> BuiltinResult<Invocation> {
473    if args.len() < 2 {
474        return Err(imwrite_error_with_detail(
475            &IMWRITE_ERROR_INVALID_ARGUMENT,
476            "expected image data and filename",
477        ));
478    }
479
480    let (image, map, filename_index) = if is_string_like(&args[1]) {
481        (args[0].clone(), None, 1usize)
482    } else {
483        if args.len() < 3 {
484            return Err(imwrite_error_with_detail(
485                &IMWRITE_ERROR_INVALID_ARGUMENT,
486                "indexed images require X, map, and filename",
487            ));
488        }
489        (args[0].clone(), Some(args[1].clone()), 2usize)
490    };
491
492    let filename = string_arg(
493        "filename",
494        &args[filename_index],
495        &IMWRITE_ERROR_INVALID_FILENAME,
496    )?;
497    if filename.trim().is_empty() {
498        return Err(imwrite_error_with_detail(
499            &IMWRITE_ERROR_INVALID_FILENAME,
500            "filename must not be empty",
501        ));
502    }
503    let path = PathBuf::from(filename);
504    let mut idx = filename_index + 1;
505
506    let mut explicit_format = None;
507    if idx < args.len() {
508        if let Some(text) = tensor::value_to_string(&args[idx]) {
509            if !is_option_name(&text) {
510                explicit_format = Some(parse_format_hint(&text)?);
511                idx += 1;
512            }
513        }
514    }
515
516    let mut options = ImwriteOptions::default();
517    let mut alpha = None;
518    while idx < args.len() {
519        let name = string_arg("option name", &args[idx], &IMWRITE_ERROR_INVALID_OPTION)?;
520        idx += 1;
521        if idx >= args.len() {
522            return Err(imwrite_error_with_detail(
523                &IMWRITE_ERROR_INVALID_OPTION,
524                format!("option '{name}' requires a value"),
525            ));
526        }
527        let value = &args[idx];
528        idx += 1;
529
530        match canonical_option_name(&name).as_str() {
531            "alpha" => {
532                let alpha_tensor = tensor_from_numeric_like(value, "Alpha")?;
533                ensure_documented_alpha_class(&alpha_tensor)?;
534                alpha = Some(alpha_tensor);
535            }
536            "quality" => {
537                let q = numeric_scalar(value, "Quality")?;
538                if !q.is_finite() || !(0.0..=100.0).contains(&q) {
539                    return Err(imwrite_error_with_detail(
540                        &IMWRITE_ERROR_INVALID_OPTION,
541                        "Quality must be a scalar from 0 to 100",
542                    ));
543                }
544                options.quality = q.round() as u8;
545            }
546            "writemode" => {
547                let mode = string_arg("WriteMode", value, &IMWRITE_ERROR_INVALID_OPTION)?;
548                options.write_mode = match mode.trim().to_ascii_lowercase().as_str() {
549                    "overwrite" => WriteMode::Overwrite,
550                    "append" => WriteMode::Append,
551                    _ => {
552                        return Err(imwrite_error_with_detail(
553                            &IMWRITE_ERROR_INVALID_OPTION,
554                            "WriteMode must be 'overwrite' or 'append'",
555                        ))
556                    }
557                };
558            }
559            "delaytime" => {
560                let delay = numeric_scalar(value, "DelayTime")?;
561                if !delay.is_finite() || delay < 0.0 {
562                    return Err(imwrite_error_with_detail(
563                        &IMWRITE_ERROR_INVALID_OPTION,
564                        "DelayTime must be a finite non-negative scalar in seconds",
565                    ));
566                }
567                options.delay_time = Some(delay);
568            }
569            "loopcount" => {
570                let count = numeric_scalar(value, "LoopCount")?;
571                if count.is_nan() || count < 0.0 {
572                    return Err(imwrite_error_with_detail(
573                        &IMWRITE_ERROR_INVALID_OPTION,
574                        "LoopCount must be non-negative or Inf",
575                    ));
576                }
577                options.loop_count = Some(count);
578            }
579            "compression" | "bitdepth" | "mode" | "disposalmethod" | "backgroundcolor"
580            | "comment" | "transparentcolor" => {
581                return Err(imwrite_error_with_detail(
582                    &IMWRITE_ERROR_INVALID_OPTION,
583                    format!("option '{name}' is not supported yet"),
584                ));
585            }
586            _ => {
587                return Err(imwrite_error_with_detail(
588                    &IMWRITE_ERROR_INVALID_OPTION,
589                    format!("unsupported option '{name}'"),
590                ))
591            }
592        }
593    }
594
595    let format = match explicit_format {
596        Some(format) => format,
597        None => infer_format_from_path(&path)?,
598    };
599
600    Ok(Invocation {
601        image,
602        map,
603        alpha,
604        path,
605        format,
606        options,
607    })
608}
609
610fn is_string_like(value: &Value) -> bool {
611    tensor::value_to_string(value).is_some()
612}
613
614fn string_arg(
615    label: &str,
616    value: &Value,
617    error: &'static BuiltinErrorDescriptor,
618) -> BuiltinResult<String> {
619    tensor::value_to_string(value).ok_or_else(|| {
620        imwrite_error_with_detail(
621            error,
622            format!("{label} must be a string scalar or char vector"),
623        )
624    })
625}
626
627fn numeric_scalar(value: &Value, label: &str) -> BuiltinResult<f64> {
628    let scalar = match value {
629        Value::Num(n) => return Ok(*n),
630        Value::Int(i) => NumericScalar::from(i.clone()),
631        Value::Bool(b) => return Ok(if *b { 1.0 } else { 0.0 }),
632        Value::Tensor(t) if tensor::is_scalar_tensor(t) => {
633            t.numeric_value_at(0).ok_or_else(|| {
634                imwrite_error_with_detail(
635                    &IMWRITE_ERROR_INVALID_OPTION,
636                    format!("{label} scalar storage is unavailable"),
637                )
638            })?
639        }
640        Value::LogicalArray(a) if a.data.len() == 1 => {
641            return Ok(if a.data[0] != 0 { 1.0 } else { 0.0 })
642        }
643        _ => Err(imwrite_error_with_detail(
644            &IMWRITE_ERROR_INVALID_OPTION,
645            format!("{label} must be a numeric scalar"),
646        ))?,
647    };
648    numeric_scalar_to_exact_f64(scalar, label)
649}
650
651fn numeric_scalar_to_exact_f64(value: NumericScalar, label: &str) -> BuiltinResult<f64> {
652    let converted = match value {
653        NumericScalar::F64(value) => return Ok(value),
654        NumericScalar::F32(value) => return Ok(f64::from(value)),
655        NumericScalar::I8(value) => f64::from(value),
656        NumericScalar::I16(value) => f64::from(value),
657        NumericScalar::I32(value) => f64::from(value),
658        NumericScalar::I64(value)
659            if crate::builtins::math::trigonometry::cos::integer_is_exact_f64(
660                &runmat_value::IntValue::I64(value),
661            ) =>
662        {
663            value as f64
664        }
665        NumericScalar::U8(value) => f64::from(value),
666        NumericScalar::U16(value) => f64::from(value),
667        NumericScalar::U32(value) => f64::from(value),
668        NumericScalar::U64(value)
669            if crate::builtins::math::trigonometry::cos::integer_is_exact_f64(
670                &runmat_value::IntValue::U64(value),
671            ) =>
672        {
673            value as f64
674        }
675        NumericScalar::I64(_) | NumericScalar::U64(_) => {
676            return Err(imwrite_error_with_detail(
677                &IMWRITE_ERROR_INVALID_OPTION,
678                format!("{label} integer value must be exactly representable as double"),
679            ))
680        }
681    };
682    Ok(converted)
683}
684
685fn canonical_option_name(name: &str) -> String {
686    name.chars()
687        .filter(|ch| !ch.is_whitespace() && *ch != '_' && *ch != '-')
688        .flat_map(char::to_lowercase)
689        .collect()
690}
691
692fn is_option_name(name: &str) -> bool {
693    matches!(
694        canonical_option_name(name).as_str(),
695        "alpha"
696            | "quality"
697            | "writemode"
698            | "delaytime"
699            | "loopcount"
700            | "compression"
701            | "bitdepth"
702            | "mode"
703            | "disposalmethod"
704            | "backgroundcolor"
705            | "comment"
706            | "transparentcolor"
707    )
708}
709
710fn parse_format_hint(value: &str) -> BuiltinResult<ImageFormat> {
711    let label = value.trim().trim_start_matches('.').to_ascii_lowercase();
712    if label.is_empty() {
713        return Err(imwrite_error_with_detail(
714            &IMWRITE_ERROR_INVALID_FORMAT,
715            "format hint must not be empty",
716        ));
717    }
718    match label.as_str() {
719        "jpg" | "jpeg" | "jpe" => Ok(ImageFormat::Jpeg),
720        "png" => Ok(ImageFormat::Png),
721        "bmp" => Ok(ImageFormat::Bmp),
722        "gif" => Ok(ImageFormat::Gif),
723        "tif" | "tiff" => Ok(ImageFormat::Tiff),
724        other => ImageFormat::from_extension(other)
725            .filter(is_supported_format)
726            .ok_or_else(|| {
727                imwrite_error_with_detail(
728                    &IMWRITE_ERROR_INVALID_FORMAT,
729                    format!("unsupported image format '{other}'"),
730                )
731            }),
732    }
733}
734
735fn infer_format_from_path(path: &Path) -> BuiltinResult<ImageFormat> {
736    ImageFormat::from_path(path)
737        .ok()
738        .filter(is_supported_format)
739        .ok_or_else(|| {
740            imwrite_error_with_detail(
741                &IMWRITE_ERROR_INVALID_FORMAT,
742                format!(
743                    "could not infer supported image format from '{}'",
744                    path.display()
745                ),
746            )
747        })
748}
749
750fn is_supported_format(format: &ImageFormat) -> bool {
751    matches!(
752        format,
753        ImageFormat::Png
754            | ImageFormat::Jpeg
755            | ImageFormat::Bmp
756            | ImageFormat::Gif
757            | ImageFormat::Tiff
758    )
759}
760
761fn tensor_from_numeric_like(value: &Value, label: &str) -> BuiltinResult<Tensor> {
762    match value {
763        Value::Tensor(t) => Ok(t.clone()),
764        Value::LogicalArray(a) => logical_to_tensor(a),
765        Value::Num(n) => Tensor::new(vec![*n], vec![1, 1]).map_err(|err| {
766            imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
767        }),
768        Value::Int(i) => Tensor::new_integer(IntegerStorage::from_scalar(i.clone()), vec![1, 1])
769            .map_err(|err| {
770                imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
771            }),
772        Value::Bool(b) => {
773            Tensor::new(vec![if *b { 1.0 } else { 0.0 }], vec![1, 1]).map_err(|err| {
774                imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
775            })
776        }
777        _ => Err(imwrite_error_with_detail(
778            &IMWRITE_ERROR_INVALID_IMAGE,
779            format!("{label} must be numeric or logical"),
780        )),
781    }
782}
783
784fn logical_to_tensor(value: &LogicalArray) -> BuiltinResult<Tensor> {
785    let data = value
786        .data
787        .iter()
788        .map(|&b| if b != 0 { 1.0 } else { 0.0 })
789        .collect::<Vec<_>>();
790    Tensor::new(data, value.shape.clone())
791        .map_err(|err| imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, err))
792}
793
794fn materialize_image(
795    image: &Value,
796    map: Option<&Value>,
797    alpha: Option<&Tensor>,
798) -> BuiltinResult<MaterializedImage> {
799    ensure_documented_image_class(image, map.is_some(), "image")?;
800    if let Some(map) = map {
801        ensure_double_colormap(map)?;
802    }
803    let tensor = tensor_from_numeric_like(image, "image")?;
804    let mut out = if let Some(map_value) = map {
805        materialize_indexed_image(&tensor, &tensor_from_numeric_like(map_value, "map")?)?
806    } else {
807        materialize_direct_image(&tensor)?
808    };
809
810    if let Some(alpha) = alpha {
811        apply_alpha(&mut out, alpha)?;
812    }
813    Ok(out)
814}
815
816fn ensure_documented_image_class(value: &Value, indexed: bool, label: &str) -> BuiltinResult<()> {
817    let supported = match value {
818        Value::Num(_) | Value::Bool(_) | Value::LogicalArray(_) => true,
819        Value::Int(IntValue::U8(_) | IntValue::U16(_)) => true,
820        Value::Tensor(tensor) => matches!(
821            tensor.numeric_dtype(),
822            NumericDType::F32 | NumericDType::F64 | NumericDType::U8 | NumericDType::U16
823        ),
824        _ => false,
825    };
826    if !supported {
827        return Err(imwrite_error_with_detail(
828            &IMWRITE_ERROR_INVALID_IMAGE,
829            format!(
830                "{label} class is unsupported; expected double, single, uint8, uint16, or logical{}",
831                if indexed { " indexed data" } else { "" }
832            ),
833        ));
834    }
835    Ok(())
836}
837
838fn ensure_double_colormap(value: &Value) -> BuiltinResult<()> {
839    let valid = matches!(value, Value::Num(_))
840        || matches!(value, Value::Tensor(tensor) if tensor.numeric_dtype() == NumericDType::F64);
841    if valid {
842        Ok(())
843    } else {
844        Err(imwrite_error_with_detail(
845            &IMWRITE_ERROR_INVALID_COLORMAP,
846            "map must be a double Nx3 colormap",
847        ))
848    }
849}
850
851fn ensure_documented_alpha_class(alpha: &Tensor) -> BuiltinResult<()> {
852    if matches!(
853        alpha.numeric_dtype(),
854        NumericDType::F32 | NumericDType::F64 | NumericDType::U8 | NumericDType::U16
855    ) {
856        Ok(())
857    } else {
858        Err(imwrite_error_with_detail(
859            &IMWRITE_ERROR_INVALID_OPTION,
860            "Alpha class must be double, single, uint8, or uint16",
861        ))
862    }
863}
864
865fn image_dimensions(tensor: &Tensor) -> BuiltinResult<(usize, usize, usize)> {
866    match tensor.shape.len() {
867        0 => Ok((1, 1, 1)),
868        1 => Ok((1, tensor.shape[0], 1)),
869        2 => Ok((tensor.shape[0], tensor.shape[1], 1)),
870        3 if matches!(tensor.shape[2], 1 | 3 | 4) => {
871            Ok((tensor.shape[0], tensor.shape[1], tensor.shape[2]))
872        }
873        _ => Err(imwrite_error_with_detail(
874            &IMWRITE_ERROR_INVALID_IMAGE,
875            "image must be MxN, MxNx3, or MxNx4",
876        )),
877    }
878}
879
880fn materialize_direct_image(tensor: &Tensor) -> BuiltinResult<MaterializedImage> {
881    let (rows, cols, channels) = image_dimensions(tensor)?;
882    let pixels = rows.checked_mul(cols).ok_or_else(|| {
883        imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
884    })?;
885    if tensor.len() != pixels * channels {
886        return Err(imwrite_error_with_detail(
887            &IMWRITE_ERROR_INVALID_IMAGE,
888            "image data length does not match shape",
889        ));
890    }
891
892    let mut data = if tensor.numeric_dtype() == NumericDType::U16 {
893        PixelData::U16(vec![0u16; pixels * channels])
894    } else {
895        PixelData::U8(vec![0u8; pixels * channels])
896    };
897    for row in 0..rows {
898        for col in 0..cols {
899            for channel in 0..channels {
900                let src = row + rows * col + pixels * channel;
901                let dst = (row * cols + col) * channels + channel;
902                let value = tensor_numeric_value(tensor, src, "image")?;
903                match &mut data {
904                    PixelData::U8(data) => data[dst] = value_to_u8(value),
905                    PixelData::U16(data) => data[dst] = value_to_u16(value),
906                }
907            }
908        }
909    }
910    Ok(MaterializedImage {
911        rows,
912        cols,
913        channels,
914        data,
915        alpha_applied: false,
916        indexed_source: false,
917    })
918}
919
920fn materialize_indexed_image(indexed: &Tensor, map: &Tensor) -> BuiltinResult<MaterializedImage> {
921    let (rows, cols, channels) = image_dimensions(indexed)?;
922    if channels != 1 {
923        return Err(imwrite_error_with_detail(
924            &IMWRITE_ERROR_INVALID_IMAGE,
925            "indexed image X must be a 2-D array",
926        ));
927    }
928    if map.shape.len() != 2 || map.shape[1] != 3 || map.shape[0] == 0 {
929        return Err(imwrite_error_with_detail(
930            &IMWRITE_ERROR_INVALID_COLORMAP,
931            "map must be an Nx3 colormap",
932        ));
933    }
934    let map_values = tensor::tensor_values_f64_cow(map);
935    if !map_values
936        .iter()
937        .all(|value| value.is_finite() && (0.0..=1.0).contains(value))
938    {
939        return Err(imwrite_error_with_detail(
940            &IMWRITE_ERROR_INVALID_COLORMAP,
941            "map values must be finite and in the range [0, 1]",
942        ));
943    }
944
945    let pixels = rows.checked_mul(cols).ok_or_else(|| {
946        imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
947    })?;
948    let byte_len = pixels.checked_mul(3).ok_or_else(|| {
949        imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
950    })?;
951    if indexed.len() != pixels || map.len() != map.shape[0] * 3 {
952        return Err(imwrite_error_with_detail(
953            &IMWRITE_ERROR_INVALID_IMAGE,
954            "indexed image or colormap data length does not match shape",
955        ));
956    }
957    let mut data = vec![0u8; byte_len];
958    for row in 0..rows {
959        for col in 0..cols {
960            let pixel = row + rows * col;
961            let map_idx = map_index(
962                tensor_numeric_value(indexed, pixel, "indexed image")?,
963                map.shape[0],
964            )?;
965            let dst = (row * cols + col) * 3;
966            for channel in 0..3 {
967                let src = map_idx + map.shape[0] * channel;
968                data[dst + channel] = value_to_u8(tensor_numeric_value(map, src, "colormap")?);
969            }
970        }
971    }
972    Ok(MaterializedImage {
973        rows,
974        cols,
975        channels: 3,
976        data: PixelData::U8(data),
977        alpha_applied: false,
978        indexed_source: true,
979    })
980}
981
982fn tensor_numeric_value(
983    tensor: &Tensor,
984    index: usize,
985    label: &str,
986) -> BuiltinResult<NumericScalar> {
987    tensor.numeric_value_at(index).ok_or_else(|| {
988        imwrite_error_with_detail(
989            &IMWRITE_ERROR_INVALID_IMAGE,
990            format!(
991                "{label} {} storage is unavailable at element {index}",
992                tensor.numeric_dtype().class_name()
993            ),
994        )
995    })
996}
997
998fn map_index(value: NumericScalar, map_rows: usize) -> BuiltinResult<usize> {
999    let index = match value {
1000        NumericScalar::F64(value) => floating_map_index(value)?,
1001        NumericScalar::F32(value) => floating_map_index(f64::from(value))?,
1002        NumericScalar::U8(value) => usize::from(value),
1003        NumericScalar::U16(value) => usize::from(value),
1004        NumericScalar::I8(value) => one_based_signed_index(i128::from(value))?,
1005        NumericScalar::I16(value) => one_based_signed_index(i128::from(value))?,
1006        NumericScalar::I32(value) => one_based_signed_index(i128::from(value))?,
1007        NumericScalar::I64(value) => one_based_signed_index(i128::from(value))?,
1008        NumericScalar::U32(value) => one_based_unsigned_index(u128::from(value))?,
1009        NumericScalar::U64(value) => one_based_unsigned_index(u128::from(value))?,
1010    };
1011    if index >= map_rows {
1012        return Err(imwrite_error_with_detail(
1013            &IMWRITE_ERROR_INVALID_IMAGE,
1014            format!(
1015                "indexed image value {} is outside the colormap",
1016                numeric_scalar_text(value)
1017            ),
1018        ));
1019    }
1020    Ok(index)
1021}
1022
1023fn floating_map_index(value: f64) -> BuiltinResult<usize> {
1024    if !value.is_finite() {
1025        return Err(imwrite_error_with_detail(
1026            &IMWRITE_ERROR_INVALID_IMAGE,
1027            "indexed image values must be finite",
1028        ));
1029    }
1030    let rounded = value.round();
1031    if rounded < 1.0 || rounded > usize::MAX as f64 {
1032        return Err(imwrite_error_with_detail(
1033            &IMWRITE_ERROR_INVALID_IMAGE,
1034            format!("indexed image value {value} is outside the colormap"),
1035        ));
1036    }
1037    Ok(rounded as usize - 1)
1038}
1039
1040fn one_based_signed_index(value: i128) -> BuiltinResult<usize> {
1041    if value < 1 {
1042        return Err(imwrite_error_with_detail(
1043            &IMWRITE_ERROR_INVALID_IMAGE,
1044            format!("indexed image value {value} is outside the colormap"),
1045        ));
1046    }
1047    usize::try_from(value - 1).map_err(|_| {
1048        imwrite_error_with_detail(
1049            &IMWRITE_ERROR_INVALID_IMAGE,
1050            format!("indexed image value {value} is outside the colormap"),
1051        )
1052    })
1053}
1054
1055fn one_based_unsigned_index(value: u128) -> BuiltinResult<usize> {
1056    if value == 0 {
1057        return Err(imwrite_error_with_detail(
1058            &IMWRITE_ERROR_INVALID_IMAGE,
1059            "indexed image value 0 is outside the colormap",
1060        ));
1061    }
1062    usize::try_from(value - 1).map_err(|_| {
1063        imwrite_error_with_detail(
1064            &IMWRITE_ERROR_INVALID_IMAGE,
1065            format!("indexed image value {value} is outside the colormap"),
1066        )
1067    })
1068}
1069
1070fn value_to_u8(value: NumericScalar) -> u8 {
1071    match value {
1072        NumericScalar::F64(value) => normalized_float_to_u8(value),
1073        NumericScalar::F32(value) => normalized_float_to_u8(f64::from(value)),
1074        integer => scaled_integer(integer, u8::MAX as u128) as u8,
1075    }
1076}
1077
1078fn value_to_u16(value: NumericScalar) -> u16 {
1079    match value {
1080        NumericScalar::F64(value) => normalized_float_to_u16(value),
1081        NumericScalar::F32(value) => normalized_float_to_u16(f64::from(value)),
1082        integer => scaled_integer(integer, u16::MAX as u128) as u16,
1083    }
1084}
1085
1086fn normalized_float_to_u8(value: f64) -> u8 {
1087    let scaled = value.clamp(0.0, 1.0) * u8::MAX as f64;
1088    if scaled.is_nan() {
1089        0
1090    } else {
1091        scaled.round() as u8
1092    }
1093}
1094
1095fn normalized_float_to_u16(value: f64) -> u16 {
1096    let scaled = value.clamp(0.0, 1.0) * u16::MAX as f64;
1097    if scaled.is_nan() {
1098        0
1099    } else {
1100        scaled.round() as u16
1101    }
1102}
1103
1104fn scaled_integer(value: NumericScalar, output_max: u128) -> u128 {
1105    let (offset, input_max) = match value {
1106        NumericScalar::I8(value) => (
1107            (i128::from(value) - i128::from(i8::MIN)) as u128,
1108            u128::from(u8::MAX),
1109        ),
1110        NumericScalar::I16(value) => (
1111            (i128::from(value) - i128::from(i16::MIN)) as u128,
1112            u128::from(u16::MAX),
1113        ),
1114        NumericScalar::I32(value) => (
1115            (i128::from(value) - i128::from(i32::MIN)) as u128,
1116            u128::from(u32::MAX),
1117        ),
1118        NumericScalar::I64(value) => (
1119            (i128::from(value) - i128::from(i64::MIN)) as u128,
1120            u128::from(u64::MAX),
1121        ),
1122        NumericScalar::U8(value) => (u128::from(value), u128::from(u8::MAX)),
1123        NumericScalar::U16(value) => (u128::from(value), u128::from(u16::MAX)),
1124        NumericScalar::U32(value) => (u128::from(value), u128::from(u32::MAX)),
1125        NumericScalar::U64(value) => (u128::from(value), u128::from(u64::MAX)),
1126        NumericScalar::F64(_) | NumericScalar::F32(_) => {
1127            unreachable!("floating image samples use normalized conversion")
1128        }
1129    };
1130    (offset * output_max + input_max / 2) / input_max
1131}
1132
1133fn numeric_scalar_text(value: NumericScalar) -> String {
1134    match value {
1135        NumericScalar::F64(value) => value.to_string(),
1136        NumericScalar::F32(value) => value.to_string(),
1137        NumericScalar::I8(value) => value.to_string(),
1138        NumericScalar::I16(value) => value.to_string(),
1139        NumericScalar::I32(value) => value.to_string(),
1140        NumericScalar::I64(value) => value.to_string(),
1141        NumericScalar::U8(value) => value.to_string(),
1142        NumericScalar::U16(value) => value.to_string(),
1143        NumericScalar::U32(value) => value.to_string(),
1144        NumericScalar::U64(value) => value.to_string(),
1145    }
1146}
1147
1148fn apply_alpha(image: &mut MaterializedImage, alpha: &Tensor) -> BuiltinResult<()> {
1149    if alpha.shape.len() != 2 || alpha.shape[0] != image.rows || alpha.shape[1] != image.cols {
1150        return Err(imwrite_error_with_detail(
1151            &IMWRITE_ERROR_INVALID_OPTION,
1152            "Alpha must be an MxN array matching the image dimensions",
1153        ));
1154    }
1155    if alpha.len() != image.rows * image.cols {
1156        return Err(imwrite_error_with_detail(
1157            &IMWRITE_ERROR_INVALID_OPTION,
1158            "Alpha data length does not match shape",
1159        ));
1160    }
1161
1162    let pixels = image.rows * image.cols;
1163    image.data = match &image.data {
1164        PixelData::U8(data) => {
1165            let mut rgba = vec![0u8; pixels * 4];
1166            for row in 0..image.rows {
1167                for col in 0..image.cols {
1168                    let pixel = row * image.cols + col;
1169                    let alpha_idx = row + image.rows * col;
1170                    let dst = pixel * 4;
1171                    match image.channels {
1172                        1 => {
1173                            let gray = data[pixel];
1174                            rgba[dst] = gray;
1175                            rgba[dst + 1] = gray;
1176                            rgba[dst + 2] = gray;
1177                        }
1178                        3 | 4 => {
1179                            let src = pixel * image.channels;
1180                            rgba[dst] = data[src];
1181                            rgba[dst + 1] = data[src + 1];
1182                            rgba[dst + 2] = data[src + 2];
1183                        }
1184                        _ => unreachable!(),
1185                    }
1186                    rgba[dst + 3] = value_to_u8(tensor_numeric_value(alpha, alpha_idx, "Alpha")?);
1187                }
1188            }
1189            PixelData::U8(rgba)
1190        }
1191        PixelData::U16(data) => {
1192            let mut rgba = vec![0u16; pixels * 4];
1193            for row in 0..image.rows {
1194                for col in 0..image.cols {
1195                    let pixel = row * image.cols + col;
1196                    let alpha_idx = row + image.rows * col;
1197                    let dst = pixel * 4;
1198                    match image.channels {
1199                        1 => {
1200                            let gray = data[pixel];
1201                            rgba[dst] = gray;
1202                            rgba[dst + 1] = gray;
1203                            rgba[dst + 2] = gray;
1204                        }
1205                        3 | 4 => {
1206                            let src = pixel * image.channels;
1207                            rgba[dst] = data[src];
1208                            rgba[dst + 1] = data[src + 1];
1209                            rgba[dst + 2] = data[src + 2];
1210                        }
1211                        _ => unreachable!(),
1212                    }
1213                    rgba[dst + 3] = value_to_u16(tensor_numeric_value(alpha, alpha_idx, "Alpha")?);
1214                }
1215            }
1216            PixelData::U16(rgba)
1217        }
1218    };
1219    image.channels = 4;
1220    image.alpha_applied = true;
1221    Ok(())
1222}
1223
1224async fn encode_image(
1225    image: &MaterializedImage,
1226    invocation: &Invocation,
1227) -> BuiltinResult<Vec<u8>> {
1228    if image.channels == 4 && !image.alpha_applied {
1229        let detail = if invocation.format == ImageFormat::Tiff {
1230            "CMYK TIFF encoding is not supported by this RunMat encoder"
1231        } else {
1232            "direct four-channel input is documented only as TIFF CMYK"
1233        };
1234        return Err(imwrite_error_with_detail(
1235            &IMWRITE_ERROR_INVALID_IMAGE,
1236            detail,
1237        ));
1238    }
1239    if invocation.options.write_mode == WriteMode::Append && invocation.format != ImageFormat::Gif {
1240        return Err(imwrite_error_with_detail(
1241            &IMWRITE_ERROR_INVALID_OPTION,
1242            "WriteMode 'append' is supported for GIF files only",
1243        ));
1244    }
1245
1246    match invocation.format {
1247        ImageFormat::Gif => {
1248            if (image.channels == 3 && !image.indexed_source)
1249                || matches!(&image.data, PixelData::U16(_))
1250            {
1251                return Err(imwrite_error_with_detail(
1252                    &IMWRITE_ERROR_INVALID_IMAGE,
1253                    "GIF direct input must be grayscale/indexed 8-bit data",
1254                ));
1255            }
1256            encode_gif(image, invocation).await
1257        }
1258        ImageFormat::Jpeg => {
1259            if image.channels == 4 {
1260                return Err(imwrite_error_with_detail(
1261                    &IMWRITE_ERROR_INVALID_OPTION,
1262                    "JPEG does not support alpha channels",
1263                ));
1264            }
1265            if matches!(&image.data, PixelData::U16(_)) {
1266                return Err(imwrite_error_with_detail(
1267                    &IMWRITE_ERROR_INVALID_IMAGE,
1268                    "16-bit JPEG encoding is not supported by this RunMat encoder",
1269                ));
1270            }
1271            write_dynamic_image(
1272                image_to_dynamic(&image_as_8bit(image), false)?,
1273                ImageOutputFormat::Jpeg(invocation.options.quality),
1274            )
1275        }
1276        ImageFormat::Bmp => {
1277            if image.channels == 4 {
1278                return Err(imwrite_error_with_detail(
1279                    &IMWRITE_ERROR_INVALID_OPTION,
1280                    "BMP alpha output is not supported",
1281                ));
1282            }
1283            if matches!(&image.data, PixelData::U16(_)) {
1284                return Err(imwrite_error_with_detail(
1285                    &IMWRITE_ERROR_INVALID_IMAGE,
1286                    "BMP does not support this 16-bit image input",
1287                ));
1288            }
1289            write_dynamic_image(
1290                image_to_dynamic(&image_as_8bit(image), false)?,
1291                ImageOutputFormat::Bmp,
1292            )
1293        }
1294        ImageFormat::Png => {
1295            write_dynamic_image(image_to_dynamic(image, true)?, ImageOutputFormat::Png)
1296        }
1297        ImageFormat::Tiff => {
1298            if image.channels == 4 {
1299                return Err(imwrite_error_with_detail(
1300                    &IMWRITE_ERROR_INVALID_IMAGE,
1301                    "CMYK TIFF encoding is not supported by this RunMat encoder",
1302                ));
1303            }
1304            write_dynamic_image(image_to_dynamic(image, true)?, ImageOutputFormat::Tiff)
1305        }
1306        _ => Err(imwrite_error_with_detail(
1307            &IMWRITE_ERROR_INVALID_FORMAT,
1308            "unsupported image format",
1309        )),
1310    }
1311}
1312
1313fn image_to_dynamic(image: &MaterializedImage, keep_alpha: bool) -> BuiltinResult<DynamicImage> {
1314    let width = u32::try_from(image.cols).map_err(|_| {
1315        imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image width is too large")
1316    })?;
1317    let height = u32::try_from(image.rows).map_err(|_| {
1318        imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image height is too large")
1319    })?;
1320
1321    match image.channels {
1322        1 => match &image.data {
1323            PixelData::U8(data) => {
1324                ImageBuffer::<Luma<u8>, _>::from_raw(width, height, data.clone())
1325                    .map(DynamicImage::ImageLuma8)
1326                    .ok_or_else(|| {
1327                        imwrite_error_with_detail(
1328                            &IMWRITE_ERROR_INVALID_IMAGE,
1329                            "invalid grayscale image buffer",
1330                        )
1331                    })
1332            }
1333            PixelData::U16(data) => {
1334                ImageBuffer::<Luma<u16>, _>::from_raw(width, height, data.clone())
1335                    .map(DynamicImage::ImageLuma16)
1336                    .ok_or_else(|| {
1337                        imwrite_error_with_detail(
1338                            &IMWRITE_ERROR_INVALID_IMAGE,
1339                            "invalid grayscale image buffer",
1340                        )
1341                    })
1342            }
1343        },
1344        3 => match &image.data {
1345            PixelData::U8(data) => ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, data.clone())
1346                .map(DynamicImage::ImageRgb8)
1347                .ok_or_else(|| {
1348                    imwrite_error_with_detail(
1349                        &IMWRITE_ERROR_INVALID_IMAGE,
1350                        "invalid RGB image buffer",
1351                    )
1352                }),
1353            PixelData::U16(data) => {
1354                ImageBuffer::<Rgb<u16>, _>::from_raw(width, height, data.clone())
1355                    .map(DynamicImage::ImageRgb16)
1356                    .ok_or_else(|| {
1357                        imwrite_error_with_detail(
1358                            &IMWRITE_ERROR_INVALID_IMAGE,
1359                            "invalid RGB image buffer",
1360                        )
1361                    })
1362            }
1363        },
1364        4 if keep_alpha => match &image.data {
1365            PixelData::U8(data) => {
1366                ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, data.clone())
1367                    .map(DynamicImage::ImageRgba8)
1368                    .ok_or_else(|| {
1369                        imwrite_error_with_detail(
1370                            &IMWRITE_ERROR_INVALID_IMAGE,
1371                            "invalid RGBA image buffer",
1372                        )
1373                    })
1374            }
1375            PixelData::U16(data) => {
1376                ImageBuffer::<Rgba<u16>, _>::from_raw(width, height, data.clone())
1377                    .map(DynamicImage::ImageRgba16)
1378                    .ok_or_else(|| {
1379                        imwrite_error_with_detail(
1380                            &IMWRITE_ERROR_INVALID_IMAGE,
1381                            "invalid RGBA image buffer",
1382                        )
1383                    })
1384            }
1385        },
1386        4 => match &image.data {
1387            PixelData::U8(data) => {
1388                let mut rgb = Vec::with_capacity(image.rows * image.cols * 3);
1389                for chunk in data.chunks_exact(4) {
1390                    rgb.extend_from_slice(&chunk[..3]);
1391                }
1392                ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
1393                    .map(DynamicImage::ImageRgb8)
1394                    .ok_or_else(|| {
1395                        imwrite_error_with_detail(
1396                            &IMWRITE_ERROR_INVALID_IMAGE,
1397                            "invalid RGB image buffer",
1398                        )
1399                    })
1400            }
1401            PixelData::U16(data) => {
1402                let mut rgb = Vec::with_capacity(image.rows * image.cols * 3);
1403                for chunk in data.chunks_exact(4) {
1404                    rgb.extend_from_slice(&chunk[..3]);
1405                }
1406                ImageBuffer::<Rgb<u16>, _>::from_raw(width, height, rgb)
1407                    .map(DynamicImage::ImageRgb16)
1408                    .ok_or_else(|| {
1409                        imwrite_error_with_detail(
1410                            &IMWRITE_ERROR_INVALID_IMAGE,
1411                            "invalid RGB image buffer",
1412                        )
1413                    })
1414            }
1415        },
1416        _ => Err(imwrite_error_with_detail(
1417            &IMWRITE_ERROR_INVALID_IMAGE,
1418            "image must have 1, 3, or 4 channels",
1419        )),
1420    }
1421}
1422
1423fn write_dynamic_image(image: DynamicImage, format: ImageOutputFormat) -> BuiltinResult<Vec<u8>> {
1424    let mut cursor = Cursor::new(Vec::new());
1425    image.write_to(&mut cursor, format).map_err(|err| {
1426        imwrite_error_with_detail(
1427            &IMWRITE_ERROR_ENCODE,
1428            format!("unable to encode image: {err}"),
1429        )
1430    })?;
1431    Ok(cursor.into_inner())
1432}
1433
1434async fn encode_gif(image: &MaterializedImage, invocation: &Invocation) -> BuiltinResult<Vec<u8>> {
1435    let mut frames = Vec::new();
1436    let mut existing_repeat = None;
1437    if invocation.options.write_mode == WriteMode::Append {
1438        // GIF append is inherently read-modify-write with the current filesystem
1439        // abstraction; providers do not expose a portable advisory/exclusive lock.
1440        let existing = runmat_filesystem::read_async(&invocation.path)
1441            .await
1442            .map_err(|err| {
1443                imwrite_error_with_detail(
1444                    &IMWRITE_ERROR_IO,
1445                    format!(
1446                        "failed to read GIF for append '{}': {err}",
1447                        invocation.path.display()
1448                    ),
1449                )
1450            })?;
1451        existing_repeat = gif_repeat_from_bytes(&existing);
1452        let decoder = GifDecoder::new(Cursor::new(existing)).map_err(|err| {
1453            imwrite_error_with_detail(
1454                &IMWRITE_ERROR_ENCODE,
1455                format!("failed to decode GIF: {err}"),
1456            )
1457        })?;
1458        for frame in decoder.into_frames() {
1459            frames.push(frame.map_err(|err| {
1460                imwrite_error_with_detail(
1461                    &IMWRITE_ERROR_ENCODE,
1462                    format!("failed to decode GIF frame: {err}"),
1463                )
1464            })?);
1465        }
1466    }
1467    frames.push(gif_frame_from_image(image, invocation.options.delay_time)?);
1468
1469    let mut bytes = Vec::new();
1470    {
1471        let mut encoder = GifEncoder::new(&mut bytes);
1472        let repeat = if let Some(loop_count) = invocation.options.loop_count {
1473            Some(loop_count_to_repeat(loop_count)?)
1474        } else {
1475            existing_repeat
1476        };
1477        if let Some(repeat) = repeat {
1478            encoder.set_repeat(repeat).map_err(|err| {
1479                imwrite_error_with_detail(
1480                    &IMWRITE_ERROR_ENCODE,
1481                    format!("failed to set GIF repeat: {err}"),
1482                )
1483            })?;
1484        }
1485        for frame in frames {
1486            encoder.encode_frame(frame).map_err(|err| {
1487                imwrite_error_with_detail(
1488                    &IMWRITE_ERROR_ENCODE,
1489                    format!("failed to encode GIF frame: {err}"),
1490                )
1491            })?;
1492        }
1493    }
1494    Ok(bytes)
1495}
1496
1497fn gif_repeat_from_bytes(bytes: &[u8]) -> Option<Repeat> {
1498    const APP_EXT_PREFIX: &[u8] = b"\x21\xFF\x0BNETSCAPE2.0\x03\x01";
1499    bytes.windows(APP_EXT_PREFIX.len() + 3).find_map(|window| {
1500        if !window.starts_with(APP_EXT_PREFIX) || window[APP_EXT_PREFIX.len() + 2] != 0 {
1501            return None;
1502        }
1503        let lo = window[APP_EXT_PREFIX.len()];
1504        let hi = window[APP_EXT_PREFIX.len() + 1];
1505        let count = u16::from_le_bytes([lo, hi]);
1506        if count == 0 {
1507            Some(Repeat::Infinite)
1508        } else {
1509            Some(Repeat::Finite(count))
1510        }
1511    })
1512}
1513
1514fn loop_count_to_repeat(loop_count: f64) -> BuiltinResult<Repeat> {
1515    if loop_count.is_infinite() {
1516        return Ok(Repeat::Infinite);
1517    }
1518    let rounded = loop_count.round();
1519    if (rounded - loop_count).abs() > 1e-6 || rounded > u16::MAX as f64 {
1520        return Err(imwrite_error_with_detail(
1521            &IMWRITE_ERROR_INVALID_OPTION,
1522            "LoopCount must be an integer between 0 and 65535, or Inf",
1523        ));
1524    }
1525    Ok(Repeat::Finite(rounded as u16))
1526}
1527
1528fn gif_frame_from_image(
1529    image: &MaterializedImage,
1530    delay_time: Option<f64>,
1531) -> BuiltinResult<Frame> {
1532    let width = u32::try_from(image.cols).map_err(|_| {
1533        imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image width is too large")
1534    })?;
1535    let height = u32::try_from(image.rows).map_err(|_| {
1536        imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image height is too large")
1537    })?;
1538
1539    let mut rgba = vec![0u8; image.rows * image.cols * 4];
1540    let data = image_data_as_u8(image);
1541    for pixel in 0..image.rows * image.cols {
1542        let dst = pixel * 4;
1543        match image.channels {
1544            1 => {
1545                let gray = data[pixel];
1546                rgba[dst] = gray;
1547                rgba[dst + 1] = gray;
1548                rgba[dst + 2] = gray;
1549                rgba[dst + 3] = 255;
1550            }
1551            3 => {
1552                let src = pixel * 3;
1553                rgba[dst..dst + 3].copy_from_slice(&data[src..src + 3]);
1554                rgba[dst + 3] = 255;
1555            }
1556            4 => {
1557                let src = pixel * 4;
1558                rgba[dst..dst + 4].copy_from_slice(&data[src..src + 4]);
1559            }
1560            _ => unreachable!(),
1561        }
1562    }
1563    let image = RgbaImage::from_raw(width, height, rgba).ok_or_else(|| {
1564        imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "invalid GIF frame buffer")
1565    })?;
1566    let delay = delay_time
1567        .map(|seconds| Delay::from_saturating_duration(Duration::from_secs_f64(seconds)))
1568        .unwrap_or_else(|| Delay::from_numer_denom_ms(0, 1));
1569    Ok(Frame::from_parts(image, 0, 0, delay))
1570}
1571
1572fn image_data_as_u8(image: &MaterializedImage) -> Vec<u8> {
1573    match &image.data {
1574        PixelData::U8(data) => data.clone(),
1575        PixelData::U16(data) => data
1576            .iter()
1577            .map(|value| ((*value as f64) / 257.0).round().clamp(0.0, 255.0) as u8)
1578            .collect(),
1579    }
1580}
1581
1582fn image_as_8bit(image: &MaterializedImage) -> MaterializedImage {
1583    MaterializedImage {
1584        rows: image.rows,
1585        cols: image.cols,
1586        channels: image.channels,
1587        data: PixelData::U8(image_data_as_u8(image)),
1588        alpha_applied: image.alpha_applied,
1589        indexed_source: image.indexed_source,
1590    }
1591}
1592
1593fn imwrite_error_with_detail(
1594    error: &'static BuiltinErrorDescriptor,
1595    message: impl Into<String>,
1596) -> RuntimeError {
1597    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
1598    if let Some(identifier) = error.identifier {
1599        builder = builder.with_identifier(identifier);
1600    }
1601    builder.build()
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606    use super::*;
1607    use futures::executor::block_on;
1608    use image::io::Reader as ImageReader;
1609    use runmat_value::IntegerStorage;
1610    use std::fs;
1611    use tempfile::tempdir;
1612
1613    fn tensor(data: Vec<f64>, shape: Vec<usize>, dtype: NumericDType) -> Tensor {
1614        Tensor::new_with_dtype(data, shape, dtype).expect("tensor")
1615    }
1616
1617    fn typed_tensor(storage: IntegerStorage, shape: Vec<usize>) -> Tensor {
1618        Tensor::new_integer(storage, shape).expect("integer tensor")
1619    }
1620
1621    fn call(args: Vec<Value>) -> BuiltinResult<Value> {
1622        block_on(imwrite_builtin(args))
1623    }
1624
1625    #[test]
1626    fn writes_png_rgb_and_round_trips_layout() {
1627        let dir = tempdir().unwrap();
1628        let path = dir.path().join("rgb.png");
1629        let rgb = tensor(
1630            vec![255.0, 0.0, 0.0, 0.0, 0.0, 255.0],
1631            vec![1, 2, 3],
1632            NumericDType::U8,
1633        );
1634
1635        call(vec![
1636            Value::Tensor(rgb),
1637            Value::from(path.to_string_lossy().as_ref()),
1638        ])
1639        .unwrap();
1640
1641        let decoded = ImageReader::open(&path)
1642            .unwrap()
1643            .decode()
1644            .unwrap()
1645            .to_rgb8();
1646        assert_eq!(decoded.dimensions(), (2, 1));
1647        assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1648        assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1649    }
1650
1651    #[test]
1652    fn writes_typed_integer_png_rgb_from_exact_storage() {
1653        let dir = tempdir().unwrap();
1654        let path = dir.path().join("typed-rgb.png");
1655        let rgb = typed_tensor(
1656            IntegerStorage::U8(vec![255, 0, 0, 0, 0, 255]),
1657            vec![1, 2, 3],
1658        );
1659
1660        call(vec![
1661            Value::Tensor(rgb),
1662            Value::from(path.to_string_lossy().as_ref()),
1663        ])
1664        .unwrap();
1665
1666        let decoded = ImageReader::open(&path)
1667            .unwrap()
1668            .decode()
1669            .unwrap()
1670            .to_rgb8();
1671        assert_eq!(decoded.dimensions(), (2, 1));
1672        assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1673        assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1674    }
1675
1676    #[test]
1677    fn writes_png_alpha_option() {
1678        let dir = tempdir().unwrap();
1679        let path = dir.path().join("alpha.png");
1680        let image = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
1681        let alpha = tensor(vec![0.5], vec![1, 1], NumericDType::F64);
1682
1683        call(vec![
1684            Value::Tensor(image),
1685            Value::from(path.to_string_lossy().as_ref()),
1686            Value::from("Alpha"),
1687            Value::Tensor(alpha),
1688        ])
1689        .unwrap();
1690
1691        let decoded = ImageReader::open(&path)
1692            .unwrap()
1693            .decode()
1694            .unwrap()
1695            .to_rgba8();
1696        assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]);
1697    }
1698
1699    #[test]
1700    fn writes_typed_integer_png_alpha_from_exact_storage() {
1701        let dir = tempdir().unwrap();
1702        let path = dir.path().join("typed-alpha.png");
1703        let image = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
1704        let alpha = typed_tensor(IntegerStorage::U8(vec![128]), vec![1, 1]);
1705
1706        call(vec![
1707            Value::Tensor(image),
1708            Value::from(path.to_string_lossy().as_ref()),
1709            Value::from("Alpha"),
1710            Value::Tensor(alpha),
1711        ])
1712        .unwrap();
1713
1714        let decoded = ImageReader::open(&path)
1715            .unwrap()
1716            .decode()
1717            .unwrap()
1718            .to_rgba8();
1719        assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]);
1720    }
1721
1722    #[test]
1723    fn imwrite_numeric_scalar_reads_typed_integer_tensor_exactly() {
1724        let scalar = Tensor::new_integer(
1725            runmat_value::IntegerStorage::U64(vec![u64::MAX]),
1726            vec![1, 1],
1727        )
1728        .expect("scalar");
1729        assert!(numeric_scalar(&Value::Tensor(scalar), "LoopCount").is_err());
1730        assert!(numeric_scalar(
1731            &Value::Int(runmat_value::IntValue::I64(i64::MAX)),
1732            "LoopCount"
1733        )
1734        .is_err());
1735        assert_eq!(
1736            numeric_scalar(
1737                &Value::Int(runmat_value::IntValue::I64(i64::MIN)),
1738                "LoopCount"
1739            )
1740            .unwrap(),
1741            i64::MIN as f64
1742        );
1743        let largest_exact_u64_below_max = u64::MAX - 2047;
1744        assert_eq!(
1745            numeric_scalar(
1746                &Value::Int(runmat_value::IntValue::U64(largest_exact_u64_below_max)),
1747                "LoopCount"
1748            )
1749            .unwrap(),
1750            largest_exact_u64_below_max as f64
1751        );
1752
1753        let vector = Tensor::new_integer(runmat_value::IntegerStorage::U16(vec![1, 2]), vec![1, 2])
1754            .expect("vector");
1755        assert!(numeric_scalar(&Value::Tensor(vector), "LoopCount").is_err());
1756
1757        for storage in [
1758            runmat_value::IntegerStorage::I8(vec![1]),
1759            runmat_value::IntegerStorage::I16(vec![1]),
1760            runmat_value::IntegerStorage::I32(vec![1]),
1761            runmat_value::IntegerStorage::I64(vec![1]),
1762            runmat_value::IntegerStorage::U8(vec![1]),
1763            runmat_value::IntegerStorage::U16(vec![1]),
1764            runmat_value::IntegerStorage::U32(vec![1]),
1765            runmat_value::IntegerStorage::U64(vec![1]),
1766        ] {
1767            let scalar = Tensor::new_integer(storage, vec![1, 1]).expect("scalar");
1768            assert_eq!(
1769                numeric_scalar(&Value::Tensor(scalar), "LoopCount").unwrap(),
1770                1.0
1771            );
1772        }
1773    }
1774
1775    #[test]
1776    fn integer_image_scaling_is_exact_for_every_native_class() {
1777        for (minimum, midpoint, maximum) in [
1778            (
1779                NumericScalar::I8(i8::MIN),
1780                NumericScalar::I8(0),
1781                NumericScalar::I8(i8::MAX),
1782            ),
1783            (
1784                NumericScalar::I16(i16::MIN),
1785                NumericScalar::I16(0),
1786                NumericScalar::I16(i16::MAX),
1787            ),
1788            (
1789                NumericScalar::I32(i32::MIN),
1790                NumericScalar::I32(0),
1791                NumericScalar::I32(i32::MAX),
1792            ),
1793            (
1794                NumericScalar::I64(i64::MIN),
1795                NumericScalar::I64(0),
1796                NumericScalar::I64(i64::MAX),
1797            ),
1798            (
1799                NumericScalar::U8(0),
1800                NumericScalar::U8(128),
1801                NumericScalar::U8(u8::MAX),
1802            ),
1803            (
1804                NumericScalar::U16(0),
1805                NumericScalar::U16(32768),
1806                NumericScalar::U16(u16::MAX),
1807            ),
1808            (
1809                NumericScalar::U32(0),
1810                NumericScalar::U32(1 << 31),
1811                NumericScalar::U32(u32::MAX),
1812            ),
1813            (
1814                NumericScalar::U64(0),
1815                NumericScalar::U64((1_u64 << 63) + 1),
1816                NumericScalar::U64(u64::MAX),
1817            ),
1818        ] {
1819            assert_eq!(value_to_u8(minimum), 0);
1820            assert_eq!(value_to_u8(midpoint), 128);
1821            assert_eq!(value_to_u8(maximum), u8::MAX);
1822            assert_eq!(value_to_u16(minimum), 0);
1823            assert!((32768..=32896).contains(&value_to_u16(midpoint)));
1824            assert_eq!(value_to_u16(maximum), u16::MAX);
1825        }
1826    }
1827
1828    #[test]
1829    fn indexed_integer_values_are_checked_without_floating_conversion() {
1830        assert_eq!(map_index(NumericScalar::U8(0), 2).unwrap(), 0);
1831        assert_eq!(map_index(NumericScalar::U16(1), 2).unwrap(), 1);
1832        assert_eq!(map_index(NumericScalar::I64(1), 2).unwrap(), 0);
1833        assert_eq!(map_index(NumericScalar::U64(2), 2).unwrap(), 1);
1834
1835        let error = map_index(NumericScalar::U64(u64::MAX), 2).unwrap_err();
1836        assert!(error.message.contains(&u64::MAX.to_string()));
1837        assert!(map_index(NumericScalar::I64(i64::MIN), 2).is_err());
1838    }
1839
1840    #[test]
1841    fn writes_uint16_png_without_downcasting() {
1842        let dir = tempdir().unwrap();
1843        let path = dir.path().join("gray16.png");
1844        let image = tensor(
1845            vec![0.0, 65535.0, 12345.0, 40000.0],
1846            vec![2, 2],
1847            NumericDType::U16,
1848        );
1849
1850        call(vec![
1851            Value::Tensor(image),
1852            Value::from(path.to_string_lossy().as_ref()),
1853        ])
1854        .unwrap();
1855
1856        let decoded = ImageReader::open(&path).unwrap().decode().unwrap();
1857        let gray = decoded.as_luma16().expect("expected 16-bit grayscale PNG");
1858        assert_eq!(gray.dimensions(), (2, 2));
1859        assert_eq!(gray.get_pixel(0, 0).0, [0]);
1860        assert_eq!(gray.get_pixel(0, 1).0, [65535]);
1861        assert_eq!(gray.get_pixel(1, 0).0, [12345]);
1862        assert_eq!(gray.get_pixel(1, 1).0, [40000]);
1863    }
1864
1865    #[test]
1866    fn resident_image_metadata_is_validated_and_preserved_before_file_effect() {
1867        crate::builtins::common::test_support::with_test_provider(|provider| {
1868            runmat_accelerate::ensure_residency_hooks();
1869            let dir = tempdir().unwrap();
1870            let path = dir.path().join("resident.png");
1871            let image = typed_tensor(IntegerStorage::U8(vec![255, 0, 0]), vec![1, 1, 3]);
1872            let handle = crate::builtins::common::gpu_helpers::upload_tensor(provider, &image)
1873                .expect("upload image");
1874            let handle =
1875                handle.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Explicit);
1876            runmat_accelerate_api::mark_residency(&handle);
1877            runmat_accelerate_api::record_handle_transpose(&handle, 1, 3);
1878            call(vec![
1879                Value::GpuTensor(handle.clone()),
1880                Value::from(path.to_string_lossy().as_ref()),
1881            ])
1882            .expect("resident write");
1883            assert!(path.exists());
1884            assert!(runmat_accelerate::fusion_residency::is_resident(&handle));
1885            assert_eq!(
1886                runmat_accelerate_api::handle_transpose_info(&handle),
1887                Some(runmat_accelerate_api::TransposeInfo {
1888                    base_rows: 1,
1889                    base_cols: 3,
1890                })
1891            );
1892
1893            let bad_path = dir.path().join("bad.png");
1894            runmat_accelerate_api::set_handle_class_name(&handle, "uint16");
1895            let err = call(vec![
1896                Value::GpuTensor(handle),
1897                Value::from(bad_path.to_string_lossy().as_ref()),
1898            ])
1899            .expect_err("contradictory class must reject");
1900            assert!(err.message().contains("class metadata contradicts"));
1901            assert!(!bad_path.exists());
1902        });
1903    }
1904
1905    #[test]
1906    fn single_gif_extension_rejects_before_file_effect_in_matlab_mode() {
1907        let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
1908        let dir = tempdir().unwrap();
1909        let path = dir.path().join("single.gif");
1910        let image = tensor(vec![0.5], vec![1, 1], NumericDType::F32);
1911        let err = call(vec![
1912            Value::Tensor(image),
1913            Value::from(path.to_string_lossy().as_ref()),
1914        ])
1915        .expect_err("single GIF extension must be gated");
1916        assert_eq!(
1917            err.identifier(),
1918            Some("RunMat:compatibility:ImwriteSingleGifTiffExtension")
1919        );
1920        assert!(!path.exists());
1921    }
1922
1923    #[test]
1924    fn writes_indexed_gif_with_zero_based_uint8_indices() {
1925        let dir = tempdir().unwrap();
1926        let path = dir.path().join("indexed.gif");
1927        let x = tensor(vec![0.0, 1.0], vec![1, 2], NumericDType::U8);
1928        let map = tensor(
1929            vec![1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1930            vec![2, 3],
1931            NumericDType::F64,
1932        );
1933
1934        call(vec![
1935            Value::Tensor(x),
1936            Value::Tensor(map),
1937            Value::from(path.to_string_lossy().as_ref()),
1938        ])
1939        .unwrap();
1940
1941        let decoded = ImageReader::open(&path)
1942            .unwrap()
1943            .decode()
1944            .unwrap()
1945            .to_rgb8();
1946        assert_eq!(decoded.dimensions(), (2, 1));
1947        assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1948        assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1949    }
1950
1951    #[test]
1952    fn writes_typed_integer_indexed_gif_from_exact_storage() {
1953        let dir = tempdir().unwrap();
1954        let path = dir.path().join("typed-indexed.gif");
1955        let x = typed_tensor(IntegerStorage::U8(vec![0, 1]), vec![1, 2]);
1956        let map = tensor(
1957            vec![1.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1958            vec![2, 3],
1959            NumericDType::F64,
1960        );
1961
1962        call(vec![
1963            Value::Tensor(x),
1964            Value::Tensor(map),
1965            Value::from(path.to_string_lossy().as_ref()),
1966        ])
1967        .unwrap();
1968
1969        let decoded = ImageReader::open(&path)
1970            .unwrap()
1971            .decode()
1972            .unwrap()
1973            .to_rgb8();
1974        assert_eq!(decoded.dimensions(), (2, 1));
1975        assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1976        assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1977    }
1978
1979    #[test]
1980    fn appends_gif_frame() {
1981        let dir = tempdir().unwrap();
1982        let path = dir.path().join("animated.gif");
1983        let first = typed_tensor(IntegerStorage::U8(vec![0]), vec![1, 1]);
1984        let second = typed_tensor(IntegerStorage::U8(vec![1]), vec![1, 1]);
1985        let map = tensor(
1986            vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1987            vec![2, 3],
1988            NumericDType::F64,
1989        );
1990
1991        call(vec![
1992            Value::Tensor(first),
1993            Value::Tensor(map.clone()),
1994            Value::from(path.to_string_lossy().as_ref()),
1995            Value::from("LoopCount"),
1996            Value::Num(f64::INFINITY),
1997            Value::from("DelayTime"),
1998            Value::Num(0.25),
1999        ])
2000        .unwrap();
2001        call(vec![
2002            Value::Tensor(second),
2003            Value::Tensor(map),
2004            Value::from(path.to_string_lossy().as_ref()),
2005            Value::from("WriteMode"),
2006            Value::from("append"),
2007            Value::from("DelayTime"),
2008            Value::Num(0.25),
2009        ])
2010        .unwrap();
2011
2012        let bytes = fs::read(&path).unwrap();
2013        assert!(matches!(
2014            gif_repeat_from_bytes(&bytes),
2015            Some(Repeat::Infinite)
2016        ));
2017        let decoder = GifDecoder::new(Cursor::new(bytes)).unwrap();
2018        let frames = decoder.into_frames().collect_frames().unwrap();
2019        assert_eq!(frames.len(), 2);
2020    }
2021
2022    #[test]
2023    fn rejects_alpha_for_jpeg() {
2024        let dir = tempdir().unwrap();
2025        let path = dir.path().join("bad.jpg");
2026        let image = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
2027        let alpha = tensor(vec![1.0], vec![1, 1], NumericDType::F64);
2028
2029        let err = call(vec![
2030            Value::Tensor(image),
2031            Value::from(path.to_string_lossy().as_ref()),
2032            Value::from("Alpha"),
2033            Value::Tensor(alpha),
2034        ])
2035        .unwrap_err();
2036        assert_eq!(err.identifier(), Some("RunMat:imwrite:InvalidOption"));
2037    }
2038
2039    #[test]
2040    fn descriptor_has_stable_errors() {
2041        let codes: Vec<&str> = IMWRITE_DESCRIPTOR
2042            .errors
2043            .iter()
2044            .map(|error| error.code)
2045            .collect();
2046        assert!(codes.contains(&"RM.IMWRITE.INVALID_IMAGE"));
2047        assert!(codes.contains(&"RM.IMWRITE.ENCODE"));
2048    }
2049}