Skip to main content

siplot/render/
save.rs

1//! Save the current plot view to a PNG (silx `saveGraph`).
2//!
3//! [`save_graph`] renders the data layer (background clear, image, curves) for
4//! the plot's current limits into an offscreen texture at a chosen pixel size,
5//! copies it back to the CPU, and writes a PNG. This captures the wgpu-rendered
6//! data layer; the egui-drawn chrome (axes, ticks, colorbar) is not included
7//! (`doc/design.md` §13 E1).
8//!
9//! The readback's row stride is padded to `COPY_BYTES_PER_ROW_ALIGNMENT`, and
10//! the bytes are converted to tightly packed RGBA8 (swapping channels when the
11//! surface format is BGRA). The pure byte-layout and PNG-encoding helpers are
12//! unit-tested; the GPU render + readback runs only with a real device.
13//!
14//! Beyond PNG, the raster snapshot can also be exported as PPM (P6), SVG (a
15//! base64 PNG `<image>`), uncompressed baseline TIFF (with DPI resolution
16//! tags), EPS (an embedded `colorimage` raster), and PDF (a single-page
17//! `/DeviceRGB` image XObject), mirroring silx `PlotImageFile.saveImageToFile`
18//! / `BackendBase.saveGraph(fileName, fileFormat, dpi)`.
19//! [`save_graph_with_format`] dispatches by [`SaveFormat`], and each `encode_*`
20//! helper is a pure function over the RGBA pixels so it is testable without a
21//! GPU or the filesystem.
22//!
23//! DEFERRED (not implemented here): true *vector* export of plot primitives
24//! (the SVG/EPS/PDF embed the rendered raster rather than re-emitting geometry,
25//! which would require the backend to record draw ops); and matplotlib-parity
26//! DPI scaling of the actual render (DPI is recorded in the TIFF resolution
27//! tags / JFIF density but does not rescale the rendered pixel grid). JPEG is
28//! covered by the hand-written baseline encoder in [`crate::render::jpeg`].
29
30use std::fmt;
31use std::path::Path;
32
33use egui::{Pos2, Rect};
34use egui_wgpu::{RenderState, wgpu};
35
36use crate::core::plot::Plot;
37use crate::core::transform::Scale;
38use crate::render::backend_wgpu::WgpuResources;
39
40/// Why a [`save_graph`] call failed.
41#[derive(Debug)]
42pub enum SaveError {
43    /// Writing the PNG file to disk failed.
44    Io(std::io::Error),
45    /// Encoding the pixels as PNG failed.
46    Encode(png::EncodingError),
47    /// The GPU readback (buffer map / device poll) failed, or the size was zero.
48    Readback(String),
49}
50
51impl fmt::Display for SaveError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            SaveError::Io(e) => write!(f, "save_graph: writing PNG: {e}"),
55            SaveError::Encode(e) => write!(f, "save_graph: encoding PNG: {e}"),
56            SaveError::Readback(e) => write!(f, "save_graph: GPU readback: {e}"),
57        }
58    }
59}
60
61impl std::error::Error for SaveError {
62    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
63        match self {
64            SaveError::Io(e) => Some(e),
65            SaveError::Encode(e) => Some(e),
66            SaveError::Readback(_) => None,
67        }
68    }
69}
70
71impl From<std::io::Error> for SaveError {
72    fn from(e: std::io::Error) -> Self {
73        SaveError::Io(e)
74    }
75}
76
77impl From<png::EncodingError> for SaveError {
78    fn from(e: png::EncodingError) -> Self {
79        SaveError::Encode(e)
80    }
81}
82
83/// Row stride, in bytes, for an `width`-pixel RGBA8 row padded up to wgpu's
84/// `COPY_BYTES_PER_ROW_ALIGNMENT` (the alignment `copy_texture_to_buffer`
85/// requires).
86pub(crate) fn padded_bytes_per_row(width: u32) -> u32 {
87    let unpadded = 4 * width;
88    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
89    unpadded.div_ceil(align) * align
90}
91
92/// Convert a mapped readback buffer (rows padded to `bytes_per_row`) into a
93/// tightly packed `width * height * 4` RGBA8 image, swapping R/B when the
94/// surface format is BGRA so the output is always RGBA.
95pub(crate) fn rows_to_rgba8(
96    mapped: &[u8],
97    width: u32,
98    height: u32,
99    bytes_per_row: u32,
100    format: wgpu::TextureFormat,
101) -> Vec<u8> {
102    let w = width as usize;
103    let h = height as usize;
104    let bpr = bytes_per_row as usize;
105    let row_bytes = w * 4;
106    let swap = matches!(
107        format,
108        wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
109    );
110
111    let mut out = vec![0u8; w * h * 4];
112    for row in 0..h {
113        let src = &mapped[row * bpr..row * bpr + row_bytes];
114        let dst = &mut out[row * row_bytes..(row + 1) * row_bytes];
115        if swap {
116            for (s, d) in src.chunks_exact(4).zip(dst.chunks_exact_mut(4)) {
117                d[0] = s[2];
118                d[1] = s[1];
119                d[2] = s[0];
120                d[3] = s[3];
121            }
122        } else {
123            dst.copy_from_slice(src);
124        }
125    }
126    out
127}
128
129/// Encode tightly packed `width * height` RGBA8 pixels as a PNG byte stream.
130pub fn encode_png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, png::EncodingError> {
131    let mut out = Vec::new();
132    {
133        let mut encoder = png::Encoder::new(&mut out, width, height);
134        encoder.set_color(png::ColorType::Rgba);
135        encoder.set_depth(png::BitDepth::Eight);
136        let mut writer = encoder.write_header()?;
137        writer.write_image_data(rgba)?;
138    }
139    Ok(out)
140}
141
142/// Drop the alpha channel of a tightly packed `width * height` RGBA8 buffer,
143/// returning tightly packed `width * height` RGB8 (3 bytes per pixel).
144///
145/// silx's raster image export (`PlotImageFile.saveImageToFile`) operates on an
146/// `(h, w, 3)` RGB array; the PPM body carries RGB, so the readback's RGBA is
147/// reduced to RGB by discarding alpha.
148pub fn rgba_to_rgb(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
149    let n = (width as usize) * (height as usize);
150    let mut out = Vec::with_capacity(n * 3);
151    for px in rgba.chunks_exact(4).take(n) {
152        out.push(px[0]);
153        out.push(px[1]);
154        out.push(px[2]);
155    }
156    out
157}
158
159/// Encode tightly packed `width * height` RGBA8 pixels as a binary (P6) PPM
160/// byte stream.
161///
162/// Faithful to silx `PlotImageFile.saveImageToFile` (`fileFormat == "ppm"`):
163/// the header is `P6\n<width> <height>\n255\n` followed by raw RGB bytes (the
164/// alpha channel is dropped). The header is ASCII and self-describing.
165pub fn encode_ppm(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
166    let rgb = rgba_to_rgb(rgba, width, height);
167    let header = format!("P6\n{width} {height}\n255\n");
168    let mut out = Vec::with_capacity(header.len() + rgb.len());
169    out.extend_from_slice(header.as_bytes());
170    out.extend_from_slice(&rgb);
171    out
172}
173
174/// Encode a 2D `uint8` array `(height, width)` (row-major / C-order) as a NumPy
175/// `.npy` v1.0 byte stream.
176///
177/// Mirrors `numpy.save` of a `uint8` array (silx `MaskToolsWidget.save(..,
178/// "npy")`): a `\x93NUMPY` magic, a `\x01\x00` version, a little-endian `u16`
179/// header length, then the ASCII header dict
180/// `{'descr': '|u1', 'fortran_order': False, 'shape': (h, w), }` padded with
181/// spaces so the whole preamble length is a multiple of 64 and terminated by a
182/// newline, then the raw C-order bytes. Pure (no GPU / filesystem) so it is
183/// directly unit-testable; `data` is expected to be `height * width` bytes long.
184pub fn encode_mask_npy(height: u32, width: u32, data: &[u8]) -> Vec<u8> {
185    const MAGIC: &[u8] = b"\x93NUMPY";
186    let header =
187        format!("{{'descr': '|u1', 'fortran_order': False, 'shape': ({height}, {width}), }}");
188    // Preamble = magic(6) + version(2) + header-len(2) + header + '\n',
189    // padded with spaces so the whole preamble length is a multiple of 64.
190    let unpadded = MAGIC.len() + 2 + 2 + header.len() + 1;
191    let pad = (64 - (unpadded % 64)) % 64;
192    let header_len = header.len() + pad + 1; // padding + trailing newline
193    debug_assert!(header_len <= u16::MAX as usize);
194
195    let mut out = Vec::with_capacity(unpadded + pad + data.len());
196    out.extend_from_slice(MAGIC);
197    out.extend_from_slice(&[1u8, 0u8]); // version 1.0
198    out.extend_from_slice(&(header_len as u16).to_le_bytes());
199    out.extend_from_slice(header.as_bytes());
200    out.extend(std::iter::repeat_n(b' ', pad));
201    out.push(b'\n');
202    out.extend_from_slice(data);
203    out
204}
205
206/// Decode a 2D `uint8` NumPy `.npy` byte stream into `(height, width, data)` in
207/// C (row-major) order.
208///
209/// Accepts only `descr` of `|u1` / `<u1` / `>u1` / `u1` (uint8) with
210/// `fortran_order: False` and a 2D shape — what [`encode_mask_npy`] /
211/// `numpy.save` of a mask produces. Any other dtype, Fortran order,
212/// dimensionality, truncated body, or malformed header is an
213/// [`std::io::ErrorKind::InvalidData`] error. Pure over a byte stream so the
214/// round-trip is directly unit-testable.
215pub fn decode_mask_npy(bytes: &[u8]) -> std::io::Result<(u32, u32, Vec<u8>)> {
216    use std::io::Read;
217    let mut r = bytes;
218    let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
219
220    let mut magic = [0u8; 6];
221    r.read_exact(&mut magic)?;
222    if &magic != b"\x93NUMPY" {
223        return Err(invalid("not a .npy file (bad magic)"));
224    }
225
226    let mut version = [0u8; 2];
227    r.read_exact(&mut version)?;
228    // Header length is u16 (v1.0) or u32 (v2.0+); support both.
229    let header_len = if version[0] >= 2 {
230        let mut len = [0u8; 4];
231        r.read_exact(&mut len)?;
232        u32::from_le_bytes(len) as usize
233    } else {
234        let mut len = [0u8; 2];
235        r.read_exact(&mut len)?;
236        u16::from_le_bytes(len) as usize
237    };
238
239    let mut header_bytes = vec![0u8; header_len];
240    r.read_exact(&mut header_bytes)?;
241    let header =
242        std::str::from_utf8(&header_bytes).map_err(|_| invalid("npy header is not UTF-8"))?;
243
244    let descr =
245        npy_header_field(header, "descr").ok_or_else(|| invalid("npy header missing 'descr'"))?;
246    // uint8: '|u1' is canonical; tolerate explicit endianness markers.
247    if !matches!(descr.as_str(), "|u1" | "<u1" | ">u1" | "u1") {
248        return Err(invalid("npy mask must be uint8 ('|u1')"));
249    }
250
251    let fortran = npy_header_field(header, "fortran_order")
252        .ok_or_else(|| invalid("npy header missing 'fortran_order'"))?;
253    if fortran != "False" {
254        return Err(invalid("npy mask must be C-order (fortran_order: False)"));
255    }
256
257    let (height, width) = npy_shape_2d(header)?;
258
259    let count = (height as usize) * (width as usize);
260    let mut data = vec![0u8; count];
261    r.read_exact(&mut data)?;
262    Ok((height, width, data))
263}
264
265/// Extract the value of a `key` from a NumPy `.npy` header dict literal,
266/// stripping surrounding quotes (so `'|u1'` becomes `|u1` and the bare literal
267/// `False` stays `False`). Returns `None` if the key is absent.
268fn npy_header_field(header: &str, key: &str) -> Option<String> {
269    // Match `'key':` then take up to the next ',' or '}'.
270    let needle = format!("'{key}':");
271    let start = header.find(&needle)? + needle.len();
272    let rest = &header[start..];
273    let end = rest.find([',', '}'])?;
274    let value = rest[..end].trim();
275    Some(value.trim_matches(['\'', '"']).to_string())
276}
277
278/// Parse the `shape` tuple of a 2D NumPy `.npy` header into `(height, width)`.
279///
280/// Rejects shapes that are not exactly 2D, matching silx's mask load which only
281/// handles 2D image masks.
282fn npy_shape_2d(header: &str) -> std::io::Result<(u32, u32)> {
283    let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
284    let start = header
285        .find("'shape':")
286        .ok_or_else(|| invalid("npy header missing 'shape'"))?
287        + "'shape':".len();
288    let rest = &header[start..];
289    let open = rest.find('(').ok_or_else(|| invalid("malformed shape"))?;
290    let close = rest.find(')').ok_or_else(|| invalid("malformed shape"))?;
291    let dims: Vec<u32> = rest[open + 1..close]
292        .split(',')
293        .map(str::trim)
294        .filter(|s| !s.is_empty())
295        .map(|s| s.parse::<u32>())
296        .collect::<Result<_, _>>()
297        .map_err(|_| invalid("non-integer shape dimension"))?;
298    if dims.len() != 2 {
299        return Err(invalid("npy mask must be 2D"));
300    }
301    Ok((dims[0], dims[1]))
302}
303
304/// Encode a 2D `uint8` mask `(height, width)` (row-major / C-order) as an ESRF
305/// Data Format (EDF) byte stream.
306///
307/// Mirrors `fabio.edfimage` writing of a `uint8` image (silx
308/// `MaskToolsWidget.save(.., "edf")`): an ASCII header block opening with `{`,
309/// one `KEY = VALUE ;` line per field (`HeaderID`/`Image`/`ByteOrder`/
310/// `DataType`/`Dim_1`/`Dim_2`/`Size`), space-padded so the header block length
311/// (through the closing `}\n`) is a multiple of 512 bytes, then the raw C-order
312/// pixel bytes. `Dim_1` is the fast axis (width / columns) and `Dim_2` the slow
313/// axis (height / rows), so the byte layout matches the row-major mask directly
314/// with no transpose. Pure (no GPU / filesystem); `data` is expected to be
315/// `height * width` bytes long.
316pub fn encode_mask_edf(height: u32, width: u32, data: &[u8]) -> Vec<u8> {
317    const BLOCK: usize = 512;
318    let size = (height as usize) * (width as usize);
319    let mut header = String::from("{\n");
320    header.push_str("HeaderID = EH:000001:000000:000000 ;\n");
321    header.push_str("Image = 1 ;\n");
322    header.push_str("ByteOrder = LowByteFirst ;\n");
323    header.push_str("DataType = UnsignedByte ;\n");
324    header.push_str(&format!("Dim_1 = {width} ;\n"));
325    header.push_str(&format!("Dim_2 = {height} ;\n"));
326    header.push_str(&format!("Size = {size} ;\n"));
327    // Pad with spaces so the block (including the closing "}\n") is 512-aligned.
328    let unpadded = header.len() + 2; // + "}\n"
329    let pad = (BLOCK - (unpadded % BLOCK)) % BLOCK;
330    header.extend(std::iter::repeat_n(' ', pad));
331    header.push_str("}\n");
332
333    let mut out = Vec::with_capacity(header.len() + data.len());
334    out.extend_from_slice(header.as_bytes());
335    out.extend_from_slice(data);
336    out
337}
338
339/// Decode a 2D `uint8` EDF byte stream into `(height, width, data)` in C
340/// (row-major) order.
341///
342/// Accepts the `UnsignedByte` 2D layout that [`encode_mask_edf`] / fabio
343/// produce: the ASCII `{ … }` header supplies `Dim_1` (width), `Dim_2` (height)
344/// and `DataType`, and the pixel data is the `Dim_1 * Dim_2` bytes that begin
345/// immediately after the header's closing `}` (skipping the single `\n` the
346/// encoder writes there). Any other `DataType`, a missing/non-integer
347/// dimension, an unterminated header, or a body shorter than `Dim_1 * Dim_2` is
348/// an [`std::io::ErrorKind::InvalidData`] error. Pure over a byte stream so the
349/// round-trip is directly unit-testable.
350pub fn decode_mask_edf(bytes: &[u8]) -> std::io::Result<(u32, u32, Vec<u8>)> {
351    let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
352    let open = bytes
353        .iter()
354        .position(|&b| b == b'{')
355        .ok_or_else(|| invalid("not an EDF file (no '{')"))?;
356    let close = bytes[open..]
357        .iter()
358        .position(|&b| b == b'}')
359        .ok_or_else(|| invalid("EDF header not terminated ('}' missing)"))?
360        + open;
361    let header = std::str::from_utf8(&bytes[open + 1..close])
362        .map_err(|_| invalid("EDF header is not UTF-8"))?;
363
364    let data_type = edf_header_field(header, "DataType")
365        .ok_or_else(|| invalid("EDF header missing 'DataType'"))?;
366    if data_type != "UnsignedByte" {
367        return Err(invalid("EDF mask must be UnsignedByte"));
368    }
369    let width: u32 = edf_header_field(header, "Dim_1")
370        .ok_or_else(|| invalid("EDF header missing 'Dim_1'"))?
371        .parse()
372        .map_err(|_| invalid("EDF 'Dim_1' is not an integer"))?;
373    let height: u32 = edf_header_field(header, "Dim_2")
374        .ok_or_else(|| invalid("EDF header missing 'Dim_2'"))?
375        .parse()
376        .map_err(|_| invalid("EDF 'Dim_2' is not an integer"))?;
377
378    // The body begins right after the closing brace; our encoder (and fabio)
379    // writes a single '\n' there before the block-aligned data.
380    let mut data_start = close + 1;
381    if bytes.get(data_start) == Some(&b'\n') {
382        data_start += 1;
383    }
384    let count = (height as usize) * (width as usize);
385    if bytes.len().saturating_sub(data_start) < count {
386        return Err(invalid("EDF body shorter than Dim_1 * Dim_2"));
387    }
388    let data = bytes[data_start..data_start + count].to_vec();
389    Ok((height, width, data))
390}
391
392/// Extract the value of `key` from an EDF ASCII header (the inside of the
393/// `{ … }` block): fields are `KEY = VALUE ;`-separated. Returns `None` if the
394/// key is absent.
395fn edf_header_field(header: &str, key: &str) -> Option<String> {
396    header.split(';').find_map(|entry| {
397        let (k, v) = entry.split_once('=')?;
398        (k.trim() == key).then(|| v.trim().to_string())
399    })
400}
401
402/// Encode a 2D `uint8` mask `(height, width)` (row-major / C-order) as a
403/// single-page grayscale TIFF byte stream.
404///
405/// Mirrors silx `MaskToolsWidget.save(.., "tif")` (via fabio / `tifffile`): a
406/// 2D `uint8` image is exactly a `width × height` grayscale TIFF. Encoded with
407/// the image-rs `tiff` crate (uncompressed `Gray8`), so the row-major mask bytes
408/// map directly to scanlines. Fallible (the encoder writes into an in-memory
409/// buffer); `data` is expected to be `height * width` bytes long.
410pub fn encode_mask_tiff(height: u32, width: u32, data: &[u8]) -> std::io::Result<Vec<u8>> {
411    use tiff::encoder::{TiffEncoder, colortype::Gray8};
412    let to_io = |e: tiff::TiffError| std::io::Error::new(std::io::ErrorKind::InvalidData, e);
413    let mut cursor = std::io::Cursor::new(Vec::new());
414    let mut encoder = TiffEncoder::new(&mut cursor).map_err(to_io)?;
415    encoder
416        .write_image::<Gray8>(width, height, data)
417        .map_err(to_io)?;
418    Ok(cursor.into_inner())
419}
420
421/// Decode a single-page grayscale `uint8` TIFF byte stream into
422/// `(height, width, data)` in C (row-major) order.
423///
424/// Accepts the 2D `uint8` layout that [`encode_mask_tiff`] / fabio / `tifffile`
425/// produce — any common single-channel compression (deflate/lzw/packbits) the
426/// `tiff` crate handles. A multi-channel (e.g. RGB) image, a non-`uint8` sample
427/// type, or a body whose length is not `width * height` is an
428/// [`std::io::ErrorKind::InvalidData`] error (a mask is a single-channel 2D
429/// `uint8` array, faithful to silx loading the image as a 2D mask).
430pub fn decode_mask_tiff(bytes: &[u8]) -> std::io::Result<(u32, u32, Vec<u8>)> {
431    use tiff::decoder::{Decoder, DecodingResult};
432    let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
433    let to_io = |e: tiff::TiffError| std::io::Error::new(std::io::ErrorKind::InvalidData, e);
434
435    let mut decoder = Decoder::new(std::io::Cursor::new(bytes)).map_err(to_io)?;
436    let (width, height) = decoder.dimensions().map_err(to_io)?;
437    let data = match decoder.read_image().map_err(to_io)? {
438        DecodingResult::U8(v) => v,
439        _ => return Err(invalid("TIFF mask must be 8-bit (uint8) samples")),
440    };
441    // A 2D mask is single-channel: width*height samples. An RGB(A) image has
442    // 3/4× that and is not a mask.
443    let count = (width as usize) * (height as usize);
444    if data.len() != count {
445        return Err(invalid(
446            "TIFF mask must be single-channel (width * height uint8 samples)",
447        ));
448    }
449    Ok((height, width, data))
450}
451
452/// Write the 2D `uint8` mask to an HDF5 dataset at `data_path` inside `path`.
453///
454/// Mirrors silx `MaskToolsWidget._saveToHdf5` (gui/plot/MaskToolsWidget.py:143-174),
455/// writing the mask as a `(height, width)` `uint8` dataset. HDF5 is a
456/// random-access container, not a byte stream, so — unlike the npy/edf/tiff
457/// codecs — it operates on a file path rather than `impl Write`, via the
458/// pure-Rust `rust-hdf5` crate.
459///
460/// Faithful to silx's "a" mode: an existing file is opened for append
461/// ([`H5File::open_rw`](rust_hdf5::H5File::open_rw)) so its other datasets are
462/// preserved, and an existing dataset at `data_path` is deleted first (silx's
463/// overwrite branch); a missing file is created. The file is finalized with
464/// `close()` before it can be read back.
465pub fn write_mask_hdf5(
466    path: &std::path::Path,
467    data_path: &str,
468    height: u32,
469    width: u32,
470    data: &[u8],
471) -> std::io::Result<()> {
472    use rust_hdf5::H5File;
473    let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
474    // silx opens an existing file in append mode ("a"), else creates it.
475    let file = if path.exists() {
476        H5File::open_rw(path).map_err(to_io)?
477    } else {
478        H5File::create(path).map_err(to_io)?
479    };
480    // silx deletes an existing dataset at the path before recreating it.
481    if file.dataset_names().iter().any(|n| n == data_path) {
482        file.delete_dataset(data_path).map_err(to_io)?;
483    }
484    let ds = file
485        .new_dataset::<u8>()
486        .shape([height as usize, width as usize])
487        .create(data_path)
488        .map_err(to_io)?;
489    ds.write_raw(data).map_err(to_io)?;
490    // Finalize the file (superblock/headers) before it can be read back.
491    file.close().map_err(to_io)?;
492    Ok(())
493}
494
495/// List the paths of every 2D dataset in the HDF5 file at `path`, sorted.
496///
497/// This is the data source for silx's "Select a 2D dataset" dialog
498/// (`_selectDataset` / `DatasetDialog`, gui/plot/MaskToolsWidget.py:63-78): the
499/// dialog browses the file tree and offers the 2D datasets a mask can be loaded
500/// from. `rust-hdf5`'s [`dataset_names`](rust_hdf5::H5File::dataset_names)
501/// enumerates every dataset (full paths, nested included); the result is sorted
502/// so the auto-selection ([`read_mask_hdf5_auto`]) is deterministic.
503pub fn list_mask_datasets_hdf5(path: &std::path::Path) -> std::io::Result<Vec<String>> {
504    use rust_hdf5::H5File;
505    let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
506    let file = H5File::open(path).map_err(to_io)?;
507    let mut out = Vec::new();
508    for name in file.dataset_names() {
509        let ds = file.dataset(&name).map_err(to_io)?;
510        if ds.ndims() == 2 {
511            out.push(name);
512        }
513    }
514    out.sort();
515    Ok(out)
516}
517
518/// Read the 2D `uint8` mask stored at `data_path` in the HDF5 file at `path`,
519/// returning `(height, width, data)` in C (row-major) order.
520///
521/// Mirrors silx `MaskToolsWidget._loadFromHdf5` (gui/plot/MaskToolsWidget.py:679-696):
522/// the dataset is opened and read; a non-2D dataset is rejected. The samples are
523/// read as `uint8` (a mask is `uint8`); a dataset whose element size is not one
524/// byte is a [`rust_hdf5::Hdf5Error`] type mismatch surfaced as an I/O error. A
525/// body whose length is not `width * height` is an
526/// [`std::io::ErrorKind::InvalidData`] error.
527pub fn read_mask_hdf5(
528    path: &std::path::Path,
529    data_path: &str,
530) -> std::io::Result<(u32, u32, Vec<u8>)> {
531    use rust_hdf5::H5File;
532    let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
533    let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
534    let file = H5File::open(path).map_err(to_io)?;
535    let ds = file.dataset(data_path).map_err(to_io)?;
536    if ds.ndims() != 2 {
537        return Err(invalid("HDF5 mask dataset must be 2-dimensional"));
538    }
539    let shape = ds.shape();
540    let (height, width) = (shape[0], shape[1]);
541    let data: Vec<u8> = ds.read_raw().map_err(to_io)?;
542    if data.len() != height * width {
543        return Err(invalid(
544            "HDF5 mask body length does not match its (height, width) shape",
545        ));
546    }
547    Ok((height as u32, width as u32, data))
548}
549
550/// Read the first (lexicographically smallest path) 2D `uint8` dataset in the
551/// HDF5 file at `path`.
552///
553/// Convenience over [`read_mask_hdf5`] for the common single-dataset file:
554/// silx's `_selectDataset` dialog would auto-resolve when there is one choice.
555/// When several 2D datasets exist, the first by sorted path is read; callers that
556/// need an explicit choice enumerate with [`list_mask_datasets_hdf5`] and call
557/// [`read_mask_hdf5`]. An error is returned when the file holds no 2D dataset.
558pub fn read_mask_hdf5_auto(path: &std::path::Path) -> std::io::Result<(u32, u32, Vec<u8>)> {
559    let datasets = list_mask_datasets_hdf5(path)?;
560    let first = datasets.first().ok_or_else(|| {
561        std::io::Error::new(
562            std::io::ErrorKind::InvalidData,
563            "HDF5 file contains no 2D dataset to load a mask from",
564        )
565    })?;
566    read_mask_hdf5(path, first)
567}
568
569/// Read an HDF5 dataset's elements as `f32`, dispatching on the stored element
570/// byte size.
571///
572/// CAVEAT: `rust-hdf5` 0.2's public dataset API surfaces only the *byte size* of
573/// an element ([`element_size`](rust_hdf5::H5Dataset::element_size)), not its
574/// datatype *class*. So this reads a 4-byte element as `f32` and an 8-byte
575/// element as `f64` (cast to `f32`) — correct for float datasets (the common
576/// scientific-image case), but a 4-byte *integer* dataset would be reinterpreted
577/// as `f32` bit-for-bit (wrong values), as the crate cannot distinguish them.
578/// Datasets with any other element size are rejected. This is a documented
579/// limitation of the dependency, not silently masked. `elements` is the expected
580/// element count, used to reject a short/long read.
581fn read_hdf5_elements_as_f32(
582    ds: &rust_hdf5::H5Dataset,
583    elements: usize,
584) -> std::io::Result<Vec<f32>> {
585    let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
586    let invalid = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
587    let data: Vec<f32> = match ds.element_size() {
588        4 => ds.read_raw::<f32>().map_err(to_io)?,
589        8 => ds
590            .read_raw::<f64>()
591            .map_err(to_io)?
592            .into_iter()
593            .map(|x| x as f32)
594            .collect(),
595        n => {
596            return Err(invalid(format!(
597                "unsupported HDF5 element size {n} (only 4-byte f32 / 8-byte f64 images are read)"
598            )));
599        }
600    };
601    if data.len() != elements {
602        return Err(invalid(format!(
603            "HDF5 image body length {} does not match its shape ({elements} elements)",
604            data.len()
605        )));
606    }
607    Ok(data)
608}
609
610/// Read a 2D HDF5 dataset at `data_path` as `(height, width, row-major f32)`
611/// (the `(rows, cols)` convention shared with [`read_mask_hdf5`]).
612///
613/// This is the on-demand image read behind [`crate::Hdf5FrameLoader`] (the
614/// `ImageStack` lazy path / silx `silx.io.get_data` of a `DataUrl`). A non-2D
615/// dataset is rejected. Only float datasets are read (4-byte `f32` / 8-byte
616/// `f64`→`f32`); `rust-hdf5` 0.2 exposes only an element's byte size, not its
617/// datatype class, so a same-width integer dataset would be reinterpreted — a
618/// documented limitation of the dependency, with other element sizes rejected.
619pub fn read_image_hdf5(
620    path: &std::path::Path,
621    data_path: &str,
622) -> std::io::Result<(u32, u32, Vec<f32>)> {
623    use rust_hdf5::H5File;
624    let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
625    let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
626    let file = H5File::open(path).map_err(to_io)?;
627    let ds = file.dataset(data_path).map_err(to_io)?;
628    if ds.ndims() != 2 {
629        return Err(invalid("HDF5 image dataset must be 2-dimensional"));
630    }
631    let shape = ds.shape();
632    let (height, width) = (shape[0], shape[1]);
633    let data = read_hdf5_elements_as_f32(&ds, height.saturating_mul(width))?;
634    Ok((height as u32, width as u32, data))
635}
636
637/// Read one 2D slice (frame `index` along axis 0) of a 3D HDF5 dataset at
638/// `data_path` as `(height, width, row-major f32)`.
639///
640/// This is the lazy-stack read for a `[N, H, W]` image stack stored as a single
641/// 3D dataset: only the requested frame is read off disk
642/// ([`read_slice`](rust_hdf5::H5Dataset::read_slice)), which is the point of the
643/// lazy path. A non-3D dataset or an out-of-range `index` is rejected. Only
644/// float datasets are read (4-byte `f32` / 8-byte `f64`→`f32`); see
645/// [`read_image_hdf5`] for the float-dtype caveat.
646pub fn read_image_hdf5_slice(
647    path: &std::path::Path,
648    data_path: &str,
649    index: usize,
650) -> std::io::Result<(u32, u32, Vec<f32>)> {
651    use rust_hdf5::H5File;
652    let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
653    let invalid = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
654    let file = H5File::open(path).map_err(to_io)?;
655    let ds = file.dataset(data_path).map_err(to_io)?;
656    if ds.ndims() != 3 {
657        return Err(invalid(
658            "HDF5 image-stack dataset must be 3-dimensional".into(),
659        ));
660    }
661    let shape = ds.shape();
662    let (n, height, width) = (shape[0], shape[1], shape[2]);
663    if index >= n {
664        return Err(invalid(format!(
665            "HDF5 slice index {index} out of range (stack has {n} frames)"
666        )));
667    }
668    let elements = height.saturating_mul(width);
669    let data: Vec<f32> = match ds.element_size() {
670        4 => ds
671            .read_slice::<f32>(&[index, 0, 0], &[1, height, width])
672            .map_err(to_io)?,
673        8 => ds
674            .read_slice::<f64>(&[index, 0, 0], &[1, height, width])
675            .map_err(to_io)?
676            .into_iter()
677            .map(|x| x as f32)
678            .collect(),
679        sz => {
680            return Err(invalid(format!(
681                "unsupported HDF5 element size {sz} (only 4-byte f32 / 8-byte f64 images are read)"
682            )));
683        }
684    };
685    if data.len() != elements {
686        return Err(invalid(format!(
687            "HDF5 slice body length {} does not match its shape ({elements} elements)",
688            data.len()
689        )));
690    }
691    Ok((height as u32, width as u32, data))
692}
693
694/// Encode a 2D mask as an Andy Hammersley fit2d `.msk` byte stream.
695///
696/// Faithful port of fabio `Fit2dMaskImage.write` (fabio/fit2dmaskimage.py:118-142).
697/// The format is a 1024-byte header (`MASK` magic — bytes 0/4/8/12 = `M`/`A`/`S`/`K`
698/// — `dim1`=width at byte 16 and `dim2`=height at byte 20 as little-endian `u32`,
699/// byte 24 = 1) followed by one bit per pixel, LSB-first, packed into rows of
700/// `((width + 31) / 32)` 32-bit ints (`num_ints * 4` bytes/row, trailing bits
701/// zero). fit2d masks are **binary**: every non-zero mask level is written as a
702/// set bit, so multi-level information is collapsed to masked/unmasked (exactly
703/// as fabio writes `self.data != 0`). Infallible (an in-memory buffer), like the
704/// npy/edf codecs; `data` is expected to be `height * width` bytes.
705pub fn encode_mask_msk(height: u32, width: u32, data: &[u8]) -> Vec<u8> {
706    let (h, w) = (height as usize, width as usize);
707    let num_ints = w.div_ceil(32);
708    let bytes_per_row = num_ints * 4;
709    let mut out = vec![0u8; 1024 + h * bytes_per_row];
710    // Header magic + dimensions (little-endian), mirroring fabio's writer.
711    out[0] = b'M';
712    out[4] = b'A';
713    out[8] = b'S';
714    out[12] = b'K';
715    out[16..20].copy_from_slice(&width.to_le_bytes());
716    out[20..24].copy_from_slice(&height.to_le_bytes());
717    out[24] = 1;
718    // Bit-pack each row: pixel column c -> byte c/8, bit c%8 (LSB-first).
719    for y in 0..h {
720        let row = 1024 + y * bytes_per_row;
721        for x in 0..w {
722            if data[y * w + x] != 0 {
723                out[row + (x >> 3)] |= 1 << (x & 7);
724            }
725        }
726    }
727    out
728}
729
730/// Decode a fit2d `.msk` byte stream into `(height, width, data)` in C
731/// (row-major) order; the returned mask is binary (`0`/`1`).
732///
733/// Faithful port of fabio `Fit2dMaskImage._readheader` + `read`
734/// (fabio/fit2dmaskimage.py:55-116): the 1024-byte header is validated against
735/// the `MASK` magic, `dim1`/`dim2` give width/height, and the bit-packed body
736/// (LSB-first, `((width + 31) / 32)` 32-bit ints per row) is expanded to one
737/// `uint8` per pixel (`1` where the bit is set). A missing/short header, a bad
738/// magic, or a body shorter than `height * ((width + 31) / 32) * 4` bytes is an
739/// [`std::io::ErrorKind::InvalidData`] error.
740pub fn decode_mask_msk(bytes: &[u8]) -> std::io::Result<(u32, u32, Vec<u8>)> {
741    let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
742    if bytes.len() < 1024 {
743        return Err(invalid(
744            "fit2d mask file is shorter than its 1024-byte header",
745        ));
746    }
747    // Magic: 'M','A','S','K' at byte offsets 0/4/8/12 (one per 32-bit slot).
748    if bytes[0] != b'M' || bytes[4] != b'A' || bytes[8] != b'S' || bytes[12] != b'K' {
749        return Err(invalid("Not a fit2d mask file (bad MASK magic)"));
750    }
751    let width = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
752    let height = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
753    let (h, w) = (height as usize, width as usize);
754    let num_ints = w.div_ceil(32);
755    let bytes_per_row = num_ints * 4;
756    let total = h * bytes_per_row;
757    if bytes.len() < 1024 + total {
758        return Err(invalid(
759            "fit2d mask body is shorter than its (height, width) shape requires",
760        ));
761    }
762    let mut data = vec![0u8; w * h];
763    for y in 0..h {
764        let row = 1024 + y * bytes_per_row;
765        for x in 0..w {
766            let bit = (bytes[row + (x >> 3)] >> (x & 7)) & 1;
767            data[y * w + x] = bit;
768        }
769    }
770    Ok((height, width, data))
771}
772
773/// Standard base64 alphabet (RFC 4648), used by [`encode_svg`].
774const BASE64_ALPHABET: &[u8; 64] =
775    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
776
777/// Encode bytes as standard (RFC 4648) base64 with `=` padding.
778///
779/// Implemented inline so the SVG export needs no external base64 crate
780/// (mirrors silx using the stdlib `base64.b64encode`).
781fn base64_encode(data: &[u8]) -> String {
782    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
783    for chunk in data.chunks(3) {
784        let b0 = chunk[0] as u32;
785        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
786        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
787        let n = (b0 << 16) | (b1 << 8) | b2;
788        out.push(BASE64_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
789        out.push(BASE64_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
790        if chunk.len() > 1 {
791            out.push(BASE64_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
792        } else {
793            out.push('=');
794        }
795        if chunk.len() > 2 {
796            out.push(BASE64_ALPHABET[(n & 0x3F) as usize] as char);
797        } else {
798            out.push('=');
799        }
800    }
801    out
802}
803
804/// Encode tightly packed `width * height` RGB8 pixels (3 bytes/pixel) as PNG.
805fn encode_rgb_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, png::EncodingError> {
806    let mut out = Vec::new();
807    {
808        let mut encoder = png::Encoder::new(&mut out, width, height);
809        encoder.set_color(png::ColorType::Rgb);
810        encoder.set_depth(png::BitDepth::Eight);
811        let mut writer = encoder.write_header()?;
812        writer.write_image_data(rgb)?;
813    }
814    Ok(out)
815}
816
817/// Encode tightly packed `width * height` RGBA8 pixels as an SVG document that
818/// embeds the raster as a base64 PNG `<image>`.
819///
820/// Faithful to silx `PlotImageFile.saveImageToFile` (`fileFormat == "svg"`):
821/// the same XML declaration, SVG 1.1 DOCTYPE, root `<svg>` carrying `width`/
822/// `height` in px, and a single `<image xlink:href="data:image/png;base64,…">`
823/// placed at `x=0 y=0` with the same width/height and `id="image"`. silx tracks
824/// no vector primitives in its raster path, so the rendered bitmap is embedded
825/// rather than re-emitted as vector geometry (see Defer note in the module).
826///
827/// The embedded PNG is RGB (alpha dropped) to match silx's `(h, w, 3)` array.
828pub fn encode_svg(rgba: &[u8], width: u32, height: u32) -> Result<String, png::EncodingError> {
829    let rgb = rgba_to_rgb(rgba, width, height);
830    let png = encode_rgb_png(&rgb, width, height)?;
831    let b64 = base64_encode(&png);
832    let mut s = String::new();
833    s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
834    s.push_str("<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n");
835    s.push_str("  \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n");
836    s.push_str("<svg xmlns:xlink=\"http://www.w3.org/1999/xlink\"\n");
837    s.push_str("     xmlns=\"http://www.w3.org/2000/svg\"\n");
838    s.push_str("     version=\"1.1\"\n");
839    s.push_str(&format!("     width=\"{width}\"\n"));
840    s.push_str(&format!("     height=\"{height}\">\n"));
841    s.push_str("    <image xlink:href=\"data:image/png;base64,");
842    s.push_str(&b64);
843    s.push_str("\"\n");
844    s.push_str("           x=\"0\"\n");
845    s.push_str("           y=\"0\"\n");
846    s.push_str(&format!("           width=\"{width}\"\n"));
847    s.push_str(&format!("           height=\"{height}\"\n"));
848    s.push_str("           id=\"image\" />\n");
849    s.push_str("</svg>");
850    Ok(s)
851}
852
853/// Encode tightly packed `width * height` RGBA8 pixels as an uncompressed
854/// baseline TIFF (RGB, alpha dropped) with the requested resolution.
855///
856/// Hand-written little-endian (`II`/42) baseline TIFF — no external crate. The
857/// IFD carries the baseline required tags plus resolution:
858///
859/// - 256 ImageWidth (LONG), 257 ImageLength (LONG)
860/// - 258 BitsPerSample (3 × SHORT = 8,8,8, stored out-of-line)
861/// - 259 Compression = 1 (none)
862/// - 262 PhotometricInterpretation = 2 (RGB)
863/// - 273 StripOffsets (LONG, single strip)
864/// - 277 SamplesPerPixel = 3
865/// - 278 RowsPerStrip = `height`
866/// - 279 StripByteCounts (LONG = width·height·3)
867/// - 282 XResolution (RATIONAL = `dpi`/1), 283 YResolution (RATIONAL)
868/// - 296 ResolutionUnit = 2 (inch)
869///
870/// Tags are emitted in ascending ID order as the baseline spec requires. This
871/// extends silx's TIFF path (which delegates to fabio's `TiffIO.writeImage`)
872/// with explicit DPI resolution tags, as `saveGraph(..., dpi)` requests.
873pub fn encode_tiff(rgba: &[u8], width: u32, height: u32, dpi: u32) -> Vec<u8> {
874    let rgb = rgba_to_rgb(rgba, width, height);
875    let dpi = dpi.max(1);
876
877    // 12 IFD entries: 256, 257, 258, 259, 262, 273, 277, 278, 279, 282, 283, 296.
878    const N_ENTRIES: u16 = 12;
879    // Header (8) + entry count (2) + entries (12·N) + next-IFD offset (4).
880    let ifd_start: u32 = 8;
881    let ifd_len: u32 = 2 + 12 * (N_ENTRIES as u32) + 4;
882    // Out-of-line data that follows the IFD, in the order it is written:
883    //   BitsPerSample (3 × SHORT = 6 bytes),
884    //   XResolution (RATIONAL = 8 bytes), YResolution (RATIONAL = 8 bytes).
885    let after_ifd: u32 = ifd_start + ifd_len;
886    let bits_offset: u32 = after_ifd;
887    let xres_offset: u32 = bits_offset + 6;
888    let yres_offset: u32 = xres_offset + 8;
889    let strip_offset: u32 = yres_offset + 8;
890    let strip_byte_count: u32 = width * height * 3;
891
892    let mut out: Vec<u8> = Vec::with_capacity(strip_offset as usize + rgb.len());
893
894    // --- Image File Header (little-endian) ---
895    out.extend_from_slice(b"II"); // byte order: little-endian
896    out.extend_from_slice(&42u16.to_le_bytes()); // magic
897    out.extend_from_slice(&ifd_start.to_le_bytes()); // offset of first IFD
898
899    // --- Image File Directory ---
900    out.extend_from_slice(&N_ENTRIES.to_le_bytes());
901
902    // Helper closures append one 12-byte IFD entry.
903    // type codes: 3 = SHORT, 4 = LONG, 5 = RATIONAL.
904    let mut entry = |tag: u16, typ: u16, count: u32, value_or_offset: u32, is_short: bool| {
905        out.extend_from_slice(&tag.to_le_bytes());
906        out.extend_from_slice(&typ.to_le_bytes());
907        out.extend_from_slice(&count.to_le_bytes());
908        if is_short {
909            // A single SHORT value is left-justified in the 4-byte field.
910            out.extend_from_slice(&(value_or_offset as u16).to_le_bytes());
911            out.extend_from_slice(&0u16.to_le_bytes());
912        } else {
913            out.extend_from_slice(&value_or_offset.to_le_bytes());
914        }
915    };
916
917    entry(256, 4, 1, width, false); // ImageWidth (LONG)
918    entry(257, 4, 1, height, false); // ImageLength (LONG)
919    entry(258, 3, 3, bits_offset, false); // BitsPerSample (3 SHORTs, out-of-line)
920    entry(259, 3, 1, 1, true); // Compression = none
921    entry(262, 3, 1, 2, true); // Photometric = RGB
922    entry(273, 4, 1, strip_offset, false); // StripOffsets (LONG)
923    entry(277, 3, 1, 3, true); // SamplesPerPixel = 3
924    entry(278, 4, 1, height, false); // RowsPerStrip = height (one strip)
925    entry(279, 4, 1, strip_byte_count, false); // StripByteCounts (LONG)
926    entry(282, 5, 1, xres_offset, false); // XResolution (RATIONAL, out-of-line)
927    entry(283, 5, 1, yres_offset, false); // YResolution (RATIONAL, out-of-line)
928    entry(296, 3, 1, 2, true); // ResolutionUnit = inch
929
930    out.extend_from_slice(&0u32.to_le_bytes()); // next IFD offset = 0 (last)
931
932    // --- Out-of-line values ---
933    // BitsPerSample: 8,8,8.
934    out.extend_from_slice(&8u16.to_le_bytes());
935    out.extend_from_slice(&8u16.to_le_bytes());
936    out.extend_from_slice(&8u16.to_le_bytes());
937    // XResolution = dpi/1.
938    out.extend_from_slice(&dpi.to_le_bytes());
939    out.extend_from_slice(&1u32.to_le_bytes());
940    // YResolution = dpi/1.
941    out.extend_from_slice(&dpi.to_le_bytes());
942    out.extend_from_slice(&1u32.to_le_bytes());
943
944    // --- Image data (single strip) ---
945    debug_assert_eq!(out.len() as u32, strip_offset);
946    out.extend_from_slice(&rgb);
947    out
948}
949
950/// Lowercase hex digits for the PostScript/PDF ASCII-hex image bodies.
951const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";
952
953/// Append `data` as an ASCII-hex stream to `s`, wrapping lines at 40 bytes
954/// (80 hex chars) so neither PostScript nor PDF readers see an over-long line.
955fn push_ascii_hex(s: &mut String, data: &[u8]) {
956    for (i, &b) in data.iter().enumerate() {
957        s.push(HEX_DIGITS[(b >> 4) as usize] as char);
958        s.push(HEX_DIGITS[(b & 0x0F) as usize] as char);
959        if (i + 1) % 40 == 0 {
960            s.push('\n');
961        }
962    }
963}
964
965/// Encode tightly packed `width * height` RGBA8 pixels as an Encapsulated
966/// PostScript (EPS) byte stream embedding the raster (alpha dropped).
967///
968/// Like [`encode_svg`], this is the faithful raster-embedding substitute for
969/// silx's matplotlib-only vector EPS (siplot has no matplotlib dependency): a
970/// minimal EPSF-3.0 document whose body is a single `colorimage` operator. The
971/// `[W 0 0 -H 0 H]` image matrix flips Y so the first scanline (top of the
972/// image) maps to the top of the page, and the RGB samples are fed as
973/// whitespace-tolerant ASCII hex. Pure over the RGBA pixels (no GPU /
974/// filesystem) so it is directly testable.
975pub fn encode_eps(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
976    let rgb = rgba_to_rgb(rgba, width, height);
977    let mut s = String::with_capacity(200 + rgb.len() * 2);
978    s.push_str("%!PS-Adobe-3.0 EPSF-3.0\n");
979    s.push_str(&format!("%%BoundingBox: 0 0 {width} {height}\n"));
980    s.push_str("%%EndComments\n");
981    s.push_str("gsave\n");
982    // Map the unit image square onto the bounding box, then draw the raster.
983    s.push_str(&format!("{width} {height} scale\n"));
984    s.push_str(&format!("/picstr {} string def\n", width as usize * 3));
985    s.push_str(&format!(
986        "{width} {height} 8 [ {width} 0 0 -{height} 0 {height} ]\n"
987    ));
988    s.push_str("{ currentfile picstr readhexstring pop } false 3 colorimage\n");
989    push_ascii_hex(&mut s, &rgb);
990    s.push('\n');
991    s.push_str("grestore\nshowpage\n");
992    s.push_str("%%EOF\n");
993    s.into_bytes()
994}
995
996/// Encode tightly packed `width * height` RGBA8 pixels as a single-page PDF
997/// embedding the raster (alpha dropped).
998///
999/// Like [`encode_eps`], this is the faithful raster-embedding substitute for
1000/// silx's matplotlib-only vector PDF (siplot has no matplotlib dependency): a
1001/// minimal PDF-1.4 file with a catalog, one page sized to the image, and a
1002/// `/DeviceRGB` `/ASCIIHexDecode` image XObject drawn by a content stream whose
1003/// `cm` matrix scales the unit image to the page. PDF image space puts the
1004/// first sample row at the top, so feeding top-to-bottom RGB renders upright
1005/// (no Y-flip needed). The cross-reference table records each object's byte
1006/// offset. Pure over the RGBA pixels (no GPU / filesystem) so it is directly
1007/// testable.
1008pub fn encode_pdf(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
1009    let rgb = rgba_to_rgb(rgba, width, height);
1010    // Image stream: ASCII-hex samples terminated by the '>' EOD marker.
1011    let mut hex = String::with_capacity(rgb.len() * 2 + 1);
1012    push_ascii_hex(&mut hex, &rgb);
1013    hex.push('>');
1014    let img_len = hex.len();
1015
1016    // Content stream: scale the unit image to the page and paint it.
1017    let content = format!("q {width} 0 0 {height} 0 0 cm /Im0 Do Q");
1018    let content_len = content.len();
1019
1020    let mut out: Vec<u8> = Vec::with_capacity(img_len + 600);
1021    // Byte offset of each object (index = object number; slot 0 is the free head).
1022    let mut offsets = [0usize; 6];
1023
1024    out.extend_from_slice(b"%PDF-1.4\n");
1025
1026    offsets[1] = out.len();
1027    out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1028
1029    offsets[2] = out.len();
1030    out.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
1031
1032    offsets[3] = out.len();
1033    out.extend_from_slice(
1034        format!(
1035            "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] \
1036             /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n"
1037        )
1038        .as_bytes(),
1039    );
1040
1041    offsets[4] = out.len();
1042    out.extend_from_slice(
1043        format!(
1044            "4 0 obj\n<< /Type /XObject /Subtype /Image /Width {width} /Height {height} \
1045             /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode \
1046             /Length {img_len} >>\nstream\n"
1047        )
1048        .as_bytes(),
1049    );
1050    out.extend_from_slice(hex.as_bytes());
1051    out.extend_from_slice(b"\nendstream\nendobj\n");
1052
1053    offsets[5] = out.len();
1054    out.extend_from_slice(format!("5 0 obj\n<< /Length {content_len} >>\nstream\n").as_bytes());
1055    out.extend_from_slice(content.as_bytes());
1056    out.extend_from_slice(b"\nendstream\nendobj\n");
1057
1058    // Cross-reference table: 20 bytes per entry (10-digit offset, 5-digit gen).
1059    let xref_offset = out.len();
1060    out.extend_from_slice(b"xref\n0 6\n");
1061    out.extend_from_slice(b"0000000000 65535 f \n");
1062    for &off in &offsets[1..6] {
1063        out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
1064    }
1065    out.extend_from_slice(
1066        format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes(),
1067    );
1068    out
1069}
1070
1071/// Per-axis log flags `[x, y]` (1.0 = log10) for the shaders, matching a
1072/// transform's scales.
1073fn axis_log_flags(t: &crate::core::transform::Transform) -> [f32; 2] {
1074    [
1075        f32::from(t.x.scale == Scale::Log10),
1076        f32::from(t.y.scale == Scale::Log10),
1077    ]
1078}
1079
1080/// An output image format for [`save_graph_with_format`].
1081///
1082/// Faithful to silx `PlotWidget.saveGraph` / `PlotImageFile.saveImageToFile`,
1083/// which support PNG, PPM, SVG, and TIFF over a raster snapshot, plus EPS and
1084/// PDF as raster-embedding substitutes for silx's matplotlib-only vector EPS/
1085/// PDF and JPEG via the hand-written baseline encoder
1086/// ([`crate::render::jpeg`]). True vector export is not implemented (see the
1087/// module-level Defer note).
1088#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1089pub enum SaveFormat {
1090    /// PNG (RGBA), via [`encode_png`].
1091    Png,
1092    /// Binary (P6) PPM (RGB), via [`encode_ppm`].
1093    Ppm,
1094    /// SVG wrapping a base64 PNG `<image>` (RGB), via [`encode_svg`].
1095    Svg,
1096    /// Uncompressed baseline TIFF (RGB) with resolution tags, via
1097    /// [`encode_tiff`].
1098    Tiff,
1099    /// Baseline JFIF JPEG (RGB, alpha dropped), via
1100    /// [`crate::render::jpeg::encode_jpeg`].
1101    Jpeg,
1102    /// Encapsulated PostScript embedding the raster (RGB), via [`encode_eps`].
1103    Eps,
1104    /// Single-page PDF embedding the raster (RGB), via [`encode_pdf`].
1105    Pdf,
1106}
1107
1108impl SaveFormat {
1109    /// Map a file extension (case-insensitive, no leading dot) to a format.
1110    ///
1111    /// Recognizes silx's raster extensions `png`, `ppm`, `svg`, `tif`/`tiff`,
1112    /// `jpg`/`jpeg` plus `eps`/`pdf` (raster-embedding). Returns `None` for
1113    /// still-unsupported extensions (`ps`).
1114    pub fn from_extension(ext: &str) -> Option<Self> {
1115        match ext.to_ascii_lowercase().as_str() {
1116            "png" => Some(SaveFormat::Png),
1117            "ppm" => Some(SaveFormat::Ppm),
1118            "svg" => Some(SaveFormat::Svg),
1119            "tif" | "tiff" => Some(SaveFormat::Tiff),
1120            "jpg" | "jpeg" => Some(SaveFormat::Jpeg),
1121            "eps" => Some(SaveFormat::Eps),
1122            "pdf" => Some(SaveFormat::Pdf),
1123            _ => None,
1124        }
1125    }
1126
1127    /// Infer the format from a path's extension via [`Self::from_extension`].
1128    pub fn from_path(path: &Path) -> Option<Self> {
1129        path.extension()
1130            .and_then(|e| e.to_str())
1131            .and_then(Self::from_extension)
1132    }
1133}
1134
1135/// Render the plot's current view to a `size = (width, height)` pixel image and
1136/// return the readback as tightly packed RGBA8. Captures the data layer (clear,
1137/// image, curves); chrome is not included. Requires [`crate::install`] to have
1138/// run on `render_state`.
1139///
1140/// This is the single owner of the plot→pixels readback: [`save_graph`] and
1141/// [`save_graph_with_format`] encode its output to a file, and the print-preview
1142/// page editor ([`crate::PrintPreview::add_image`]) takes the same RGBA in memory
1143/// to place the plot on a printable page.
1144pub fn render_plot_rgba(
1145    render_state: &RenderState,
1146    plot: &Plot,
1147    size: (u32, u32),
1148) -> Result<Vec<u8>, SaveError> {
1149    let (w, h) = size;
1150    if w == 0 || h == 0 {
1151        return Err(SaveError::Readback("zero-size target".into()));
1152    }
1153
1154    // Build the transform for a target-sized area. The ortho mapping is area
1155    // independent, but the viewport pixel size drives the line-width expansion.
1156    let area = Rect::from_min_size(Pos2::ZERO, egui::vec2(w as f32, h as f32));
1157    let transform = plot.transform(area);
1158    let transform_right = plot.transform_y2(area);
1159    let ortho_left = transform.ortho_matrix();
1160    let axis_log_left = axis_log_flags(&transform);
1161    let (ortho_right, axis_log_right) = match &transform_right {
1162        Some(t) => (t.ortho_matrix(), axis_log_flags(t)),
1163        None => (ortho_left, axis_log_left),
1164    };
1165    // Per-extra-axis matrices (parallel to plot.extra); curves on extra axes
1166    // render in saves even though their tick chrome is not drawn here. An axis
1167    // with no range falls back to the left matrix.
1168    let mut ortho_extra = Vec::with_capacity(plot.extra.len());
1169    let mut axis_log_extra = Vec::with_capacity(plot.extra.len());
1170    for i in 0..plot.extra.len() {
1171        match plot.transform_extra(i, area) {
1172            Some(t) => {
1173                ortho_extra.push(t.ortho_matrix());
1174                axis_log_extra.push(axis_log_flags(&t));
1175            }
1176            None => {
1177                ortho_extra.push(ortho_left);
1178                axis_log_extra.push(axis_log_left);
1179            }
1180        }
1181    }
1182    let bg = egui::Rgba::from(plot.data_background).to_array();
1183
1184    let renderer = render_state.renderer.read();
1185    let res: &WgpuResources = renderer
1186        .callback_resources
1187        .get()
1188        .expect("WgpuResources not installed — call siplot::install() first");
1189    res.render_to_rgba(
1190        &render_state.device,
1191        &render_state.queue,
1192        render_state.target_format,
1193        plot.id,
1194        size,
1195        bg,
1196        ortho_left,
1197        axis_log_left,
1198        ortho_right,
1199        axis_log_right,
1200        &ortho_extra,
1201        &axis_log_extra,
1202    )
1203}
1204
1205/// Render the plot's current view to a `size = (width, height)` pixel PNG at
1206/// `path`. Captures the data layer (clear + image + curves); chrome is not
1207/// included. Requires [`crate::install`] to have run on `render_state`.
1208pub fn save_graph(
1209    render_state: &RenderState,
1210    plot: &Plot,
1211    size: (u32, u32),
1212    path: impl AsRef<Path>,
1213) -> Result<(), SaveError> {
1214    let (w, h) = size;
1215    let rgba = render_plot_rgba(render_state, plot, size)?;
1216    let png = encode_png(&rgba, w, h)?;
1217    std::fs::write(path, png)?;
1218    Ok(())
1219}
1220
1221/// Render the plot's current view and save it to `path` in the given
1222/// [`SaveFormat`], at the requested `dpi` (used where the format carries
1223/// resolution — currently TIFF). Captures the data layer only; chrome is not
1224/// included.
1225///
1226/// Faithful to silx `BackendBase.saveGraph(fileName, fileFormat, dpi)`: the
1227/// caller chooses the format explicitly and threads DPI through. For raster
1228/// formats (PNG/PPM/SVG) `dpi` is recorded only where the container supports it
1229/// (SVG width/height stay in px); TIFF writes `XResolution`/`YResolution`.
1230pub fn save_graph_with_format(
1231    render_state: &RenderState,
1232    plot: &Plot,
1233    size: (u32, u32),
1234    path: impl AsRef<Path>,
1235    format: SaveFormat,
1236    dpi: u32,
1237) -> Result<(), SaveError> {
1238    let (w, h) = size;
1239    let rgba = render_plot_rgba(render_state, plot, size)?;
1240    match format {
1241        SaveFormat::Png => {
1242            let bytes = encode_png(&rgba, w, h)?;
1243            std::fs::write(path, bytes)?;
1244        }
1245        SaveFormat::Ppm => {
1246            let bytes = encode_ppm(&rgba, w, h);
1247            std::fs::write(path, bytes)?;
1248        }
1249        SaveFormat::Svg => {
1250            let svg = encode_svg(&rgba, w, h)?;
1251            std::fs::write(path, svg)?;
1252        }
1253        SaveFormat::Tiff => {
1254            let bytes = encode_tiff(&rgba, w, h, dpi);
1255            std::fs::write(path, bytes)?;
1256        }
1257        SaveFormat::Jpeg => {
1258            let bytes = crate::render::jpeg::encode_jpeg(&rgba, w, h, dpi);
1259            std::fs::write(path, bytes)?;
1260        }
1261        SaveFormat::Eps => {
1262            let bytes = encode_eps(&rgba, w, h);
1263            std::fs::write(path, bytes)?;
1264        }
1265        SaveFormat::Pdf => {
1266            let bytes = encode_pdf(&rgba, w, h);
1267            std::fs::write(path, bytes)?;
1268        }
1269    }
1270    Ok(())
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276
1277    /// One parsed IFD entry: (type code, count, raw 4-byte value/offset field).
1278    type IfdEntry = (u16, u32, [u8; 4]);
1279    /// Map of TIFF tag ID → parsed IFD entry.
1280    type IfdTags = std::collections::HashMap<u16, IfdEntry>;
1281    /// Parsed baseline TIFF: (width, height, IFD tags, strip pixel bytes).
1282    type ParsedTiff = (u32, u32, IfdTags, Vec<u8>);
1283
1284    #[test]
1285    fn bytes_per_row_rounds_up_to_256() {
1286        assert_eq!(padded_bytes_per_row(1), 256); // 4 → 256
1287        assert_eq!(padded_bytes_per_row(64), 256); // 256 → 256 (exact)
1288        assert_eq!(padded_bytes_per_row(65), 512); // 260 → 512
1289        assert_eq!(padded_bytes_per_row(100), 512); // 400 → 512
1290    }
1291
1292    #[test]
1293    fn rows_to_rgba8_unpads_and_passes_rgba_through() {
1294        // 1×2 image, row stride padded to 256. Rgba format → no channel swap.
1295        let bpr = padded_bytes_per_row(1);
1296        let mut mapped = vec![0u8; (bpr as usize) * 2];
1297        mapped[0..4].copy_from_slice(&[10, 20, 30, 40]); // row 0
1298        mapped[bpr as usize..bpr as usize + 4].copy_from_slice(&[50, 60, 70, 80]); // row 1
1299        let out = rows_to_rgba8(&mapped, 1, 2, bpr, wgpu::TextureFormat::Rgba8UnormSrgb);
1300        assert_eq!(out, vec![10, 20, 30, 40, 50, 60, 70, 80]);
1301    }
1302
1303    #[test]
1304    fn rows_to_rgba8_swaps_bgra_to_rgba() {
1305        let bpr = padded_bytes_per_row(1);
1306        let mut mapped = vec![0u8; bpr as usize];
1307        mapped[0..4].copy_from_slice(&[30, 20, 10, 40]); // stored BGRA
1308        let out = rows_to_rgba8(&mapped, 1, 1, bpr, wgpu::TextureFormat::Bgra8UnormSrgb);
1309        assert_eq!(out, vec![10, 20, 30, 40]); // → RGBA
1310    }
1311
1312    // ── HDF5 image read (ImageStack lazy path) ──────────────────────────────
1313
1314    fn temp_h5(tag: &str) -> std::path::PathBuf {
1315        let mut path = std::env::temp_dir();
1316        path.push(format!("siplot_image_h5_{}_{}.h5", tag, std::process::id()));
1317        let _ = std::fs::remove_file(&path);
1318        path
1319    }
1320
1321    /// Write one dataset of any [`rust_hdf5::H5Type`] at `name` with `shape`.
1322    fn seed_dataset<T: rust_hdf5::types::H5Type>(
1323        path: &std::path::Path,
1324        name: &str,
1325        shape: &[usize],
1326        data: &[T],
1327    ) {
1328        use rust_hdf5::H5File;
1329        let file = if path.exists() {
1330            H5File::open_rw(path).unwrap()
1331        } else {
1332            H5File::create(path).unwrap()
1333        };
1334        let ds = file.new_dataset::<T>().shape(shape).create(name).unwrap();
1335        ds.write_raw(data).unwrap();
1336        file.close().unwrap();
1337    }
1338
1339    #[test]
1340    fn read_image_hdf5_roundtrips_f32() {
1341        let path = temp_h5("f32_2d");
1342        // 2 rows x 3 cols, row-major.
1343        seed_dataset(&path, "/img", &[2, 3], &[0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0]);
1344        let (h, w, data) = read_image_hdf5(&path, "/img").expect("read 2D f32");
1345        assert_eq!((h, w), (2, 3));
1346        assert_eq!(data, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
1347        let _ = std::fs::remove_file(&path);
1348    }
1349
1350    #[test]
1351    fn read_image_hdf5_casts_f64_to_f32() {
1352        let path = temp_h5("f64_2d");
1353        seed_dataset(&path, "/img", &[1, 2], &[1.5f64, 2.5]);
1354        let (h, w, data) = read_image_hdf5(&path, "/img").expect("read 2D f64");
1355        assert_eq!((h, w), (1, 2));
1356        assert_eq!(data, vec![1.5f32, 2.5]);
1357        let _ = std::fs::remove_file(&path);
1358    }
1359
1360    #[test]
1361    fn read_image_hdf5_rejects_non_2d() {
1362        let path = temp_h5("rank");
1363        seed_dataset(&path, "/vec", &[4], &[0.0f32, 1.0, 2.0, 3.0]);
1364        assert!(read_image_hdf5(&path, "/vec").is_err());
1365        let _ = std::fs::remove_file(&path);
1366    }
1367
1368    #[test]
1369    fn read_image_hdf5_slice_reads_one_frame() {
1370        let path = temp_h5("f32_3d");
1371        // 2 frames x 2 rows x 2 cols: frame 0 = 0..4, frame 1 = 10..14.
1372        seed_dataset(
1373            &path,
1374            "/stack",
1375            &[2, 2, 2],
1376            &[0.0f32, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0],
1377        );
1378        let (h, w, f0) = read_image_hdf5_slice(&path, "/stack", 0).expect("slice 0");
1379        assert_eq!((h, w), (2, 2));
1380        assert_eq!(f0, vec![0.0, 1.0, 2.0, 3.0]);
1381        let (_, _, f1) = read_image_hdf5_slice(&path, "/stack", 1).expect("slice 1");
1382        assert_eq!(f1, vec![10.0, 11.0, 12.0, 13.0]);
1383        let _ = std::fs::remove_file(&path);
1384    }
1385
1386    #[test]
1387    fn read_image_hdf5_slice_rejects_oob_and_non_3d() {
1388        let path = temp_h5("slice_reject");
1389        seed_dataset(&path, "/stack", &[2, 1, 1], &[0.0f32, 1.0]);
1390        // index past the last frame.
1391        assert!(read_image_hdf5_slice(&path, "/stack", 2).is_err());
1392        // a 2D dataset is not sliceable as a stack.
1393        seed_dataset(&path, "/img2d", &[1, 2], &[0.0f32, 1.0]);
1394        assert!(read_image_hdf5_slice(&path, "/img2d", 0).is_err());
1395        let _ = std::fs::remove_file(&path);
1396    }
1397
1398    #[test]
1399    fn encode_png_round_trips() {
1400        // 2×2 RGBA encoded then decoded yields the same pixels.
1401        let rgba: Vec<u8> = (0..16).map(|i| i as u8 * 16).collect();
1402        let png = encode_png(&rgba, 2, 2).expect("encode");
1403
1404        let decoder = png::Decoder::new(std::io::Cursor::new(&png));
1405        let mut reader = decoder.read_info().expect("read info");
1406        let mut buf = vec![0u8; reader.output_buffer_size().expect("buffer size")];
1407        let info = reader.next_frame(&mut buf).expect("frame");
1408        assert_eq!(info.width, 2);
1409        assert_eq!(info.height, 2);
1410        assert_eq!(info.color_type, png::ColorType::Rgba);
1411        assert_eq!(&buf[..rgba.len()], rgba.as_slice());
1412    }
1413
1414    #[test]
1415    fn save_format_from_extension_maps_silx_raster_formats() {
1416        assert_eq!(SaveFormat::from_extension("png"), Some(SaveFormat::Png));
1417        assert_eq!(SaveFormat::from_extension("PNG"), Some(SaveFormat::Png));
1418        assert_eq!(SaveFormat::from_extension("ppm"), Some(SaveFormat::Ppm));
1419        assert_eq!(SaveFormat::from_extension("svg"), Some(SaveFormat::Svg));
1420        assert_eq!(SaveFormat::from_extension("tif"), Some(SaveFormat::Tiff));
1421        assert_eq!(SaveFormat::from_extension("TIFF"), Some(SaveFormat::Tiff));
1422        assert_eq!(SaveFormat::from_extension("eps"), Some(SaveFormat::Eps));
1423        assert_eq!(SaveFormat::from_extension("EPS"), Some(SaveFormat::Eps));
1424        assert_eq!(SaveFormat::from_extension("pdf"), Some(SaveFormat::Pdf));
1425        assert_eq!(SaveFormat::from_extension("PDF"), Some(SaveFormat::Pdf));
1426        assert_eq!(SaveFormat::from_extension("jpg"), Some(SaveFormat::Jpeg));
1427        assert_eq!(SaveFormat::from_extension("JPEG"), Some(SaveFormat::Jpeg));
1428    }
1429
1430    #[test]
1431    fn save_format_rejects_still_unsupported_and_unknown_extensions() {
1432        // PostScript (`ps`) is not wired → still unsupported. (EPS, PDF, and
1433        // JPEG are now covered by the hand-written encoders.)
1434        assert_eq!(SaveFormat::from_extension("ps"), None);
1435        assert_eq!(SaveFormat::from_extension("bmp"), None);
1436        assert_eq!(SaveFormat::from_extension(""), None);
1437    }
1438
1439    #[test]
1440    fn save_format_from_path_uses_extension() {
1441        use std::path::Path;
1442        assert_eq!(
1443            SaveFormat::from_path(Path::new("/tmp/out.tiff")),
1444            Some(SaveFormat::Tiff)
1445        );
1446        assert_eq!(SaveFormat::from_path(Path::new("/tmp/noext")), None);
1447    }
1448
1449    #[test]
1450    fn rgba_to_rgb_drops_alpha() {
1451        // 2×1 RGBA → RGB; alpha bytes (4th of each quad) are removed.
1452        let rgba = [10, 20, 30, 99, 40, 50, 60, 88];
1453        let rgb = rgba_to_rgb(&rgba, 2, 1);
1454        assert_eq!(rgb, vec![10, 20, 30, 40, 50, 60]);
1455    }
1456
1457    #[test]
1458    fn encode_ppm_header_and_pixels_round_trip() {
1459        // 2×1 image with distinct pixels.
1460        let rgba = [1, 2, 3, 255, 4, 5, 6, 255];
1461        let ppm = encode_ppm(&rgba, 2, 1);
1462
1463        // Header is exactly "P6\n2 1\n255\n" then raw RGB.
1464        let header = b"P6\n2 1\n255\n";
1465        assert_eq!(&ppm[..header.len()], header);
1466        // Raw RGB body, alpha dropped.
1467        assert_eq!(&ppm[header.len()..], &[1, 2, 3, 4, 5, 6]);
1468        // Total length = header + width*height*3 (2×1 pixels × 3 channels).
1469        assert_eq!(ppm.len(), header.len() + 6);
1470    }
1471
1472    #[test]
1473    fn encode_eps_is_well_formed_and_hex_body_round_trips() {
1474        // 2×2 image with distinct pixels; the alpha channel is dropped.
1475        let rgba = [
1476            10, 20, 30, 255, // (0,0)
1477            40, 50, 60, 255, // (0,1)
1478            70, 80, 90, 255, // (1,0)
1479            100, 110, 120, 255, // (1,1)
1480        ];
1481        let eps = encode_eps(&rgba, 2, 2);
1482        let text = std::str::from_utf8(&eps).expect("EPS is ASCII");
1483
1484        assert!(text.starts_with("%!PS-Adobe-3.0 EPSF-3.0\n"));
1485        assert!(text.contains("%%BoundingBox: 0 0 2 2\n"));
1486        // The Y-flip matrix and the colorimage operator drive the raster.
1487        assert!(text.contains("2 2 8 [ 2 0 0 -2 0 2 ]"));
1488        assert!(text.contains("false 3 colorimage"));
1489        assert!(text.trim_end().ends_with("%%EOF"));
1490
1491        // The ASCII-hex body decodes back to the RGB (alpha-dropped) pixels.
1492        // Bound it to before the `grestore` trailer (whose letters are hex too).
1493        let body = text
1494            .split("colorimage\n")
1495            .nth(1)
1496            .expect("body after colorimage")
1497            .split("\ngrestore")
1498            .next()
1499            .expect("hex before grestore");
1500        let hex: String = body.chars().filter(|c| c.is_ascii_hexdigit()).collect();
1501        let decoded: Vec<u8> = hex
1502            .as_bytes()
1503            .chunks_exact(2)
1504            .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
1505            .collect();
1506        assert_eq!(decoded, rgba_to_rgb(&rgba, 2, 2));
1507    }
1508
1509    #[test]
1510    fn encode_pdf_is_well_formed_and_xref_offsets_point_at_objects() {
1511        let rgba = [
1512            10, 20, 30, 255, // (0,0)
1513            40, 50, 60, 255, // (0,1)
1514            70, 80, 90, 255, // (1,0)
1515            100, 110, 120, 255, // (1,1)
1516        ];
1517        let pdf = encode_pdf(&rgba, 2, 2);
1518        let text = std::str::from_utf8(&pdf).expect("PDF body is ASCII here");
1519
1520        assert!(text.starts_with("%PDF-1.4\n"));
1521        assert!(text.contains("/Type /Catalog"));
1522        assert!(text.contains("/MediaBox [0 0 2 2]"));
1523        assert!(text.contains("/Subtype /Image /Width 2 /Height 2"));
1524        assert!(text.contains("/ColorSpace /DeviceRGB"));
1525        assert!(text.contains("/Filter /ASCIIHexDecode"));
1526        assert!(text.trim_end().ends_with("%%EOF"));
1527
1528        // startxref points at the `xref` keyword.
1529        let sx = text.rfind("startxref\n").expect("startxref");
1530        let after = &text[sx + "startxref\n".len()..];
1531        let xref_off: usize = after
1532            .lines()
1533            .next()
1534            .unwrap()
1535            .trim()
1536            .parse()
1537            .expect("xref offset int");
1538        assert!(
1539            text[xref_off..].starts_with("xref\n"),
1540            "startxref must point at xref"
1541        );
1542
1543        // The image hex stream decodes back to the RGB (alpha-dropped) pixels.
1544        let stream_at = text.find("/ASCIIHexDecode").expect("image dict");
1545        let body = &text[stream_at..];
1546        let body = &body[body.find("stream\n").expect("stream kw") + "stream\n".len()..];
1547        let hex = &body[..body.find('>').expect("hex EOD marker")];
1548        let hex: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
1549        let decoded: Vec<u8> = hex
1550            .as_bytes()
1551            .chunks_exact(2)
1552            .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
1553            .collect();
1554        assert_eq!(decoded, rgba_to_rgb(&rgba, 2, 2));
1555    }
1556
1557    // --- NumPy .npy mask codec ---
1558
1559    #[test]
1560    fn mask_npy_round_trips_bytes_and_shape() {
1561        // A small 2x3 uint8 mask round-trips through encode -> decode with
1562        // identical shape and data.
1563        let data: Vec<u8> = vec![0, 1, 2, 250, 254, 255];
1564        let bytes = encode_mask_npy(2, 3, &data);
1565        let (h, w, out) = decode_mask_npy(&bytes).expect("decode");
1566        assert_eq!((h, w), (2, 3));
1567        assert_eq!(out, data);
1568    }
1569
1570    #[test]
1571    fn mask_npy_header_is_valid_v1_format() {
1572        let data = vec![7u8; 4];
1573        let bytes = encode_mask_npy(2, 2, &data);
1574        // Magic \x93NUMPY, version 1.0.
1575        assert_eq!(&bytes[0..6], b"\x93NUMPY");
1576        assert_eq!(&bytes[6..8], &[1, 0]);
1577        // header_len (u16 LE) and the preamble length is a multiple of 64.
1578        let header_len = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
1579        let preamble = 10 + header_len;
1580        assert_eq!(preamble % 64, 0, "preamble {preamble} not 64-aligned");
1581        // The header dict carries descr/fortran_order/shape and ends in newline.
1582        let header = std::str::from_utf8(&bytes[10..preamble]).expect("ascii header");
1583        assert!(header.contains("'descr': '|u1'"));
1584        assert!(header.contains("'fortran_order': False"));
1585        assert!(header.contains("'shape': (2, 2)"));
1586        assert!(header.ends_with('\n'));
1587        // The raw C-order body follows the preamble exactly.
1588        assert_eq!(&bytes[preamble..], data.as_slice());
1589    }
1590
1591    #[test]
1592    fn mask_npy_rejects_bad_magic_and_non_uint8() {
1593        // Bad magic.
1594        let err = decode_mask_npy(b"not-a-npy-file-at-all").unwrap_err();
1595        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1596
1597        // Valid framing but a float64 dtype is rejected.
1598        let mut bytes = encode_mask_npy(1, 1, &[0]);
1599        let header_len = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
1600        let header = std::str::from_utf8(&bytes[10..10 + header_len])
1601            .unwrap()
1602            .replace("|u1", "<f8");
1603        bytes.splice(10..10 + header_len, header.bytes());
1604        let err = decode_mask_npy(&bytes).unwrap_err();
1605        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1606    }
1607
1608    #[test]
1609    fn mask_npy_rejects_non_2d_shape() {
1610        // Reshape the header to a 3D shape; decode must reject it.
1611        let mut bytes = encode_mask_npy(1, 1, &[0]);
1612        let header_len = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
1613        let header = std::str::from_utf8(&bytes[10..10 + header_len])
1614            .unwrap()
1615            .replace("(1, 1)", "(1, 1, 1)");
1616        // Keep total length stable by trimming/padding spaces before newline.
1617        let mut header = header;
1618        while header.len() < header_len {
1619            header.insert(header.len() - 1, ' ');
1620        }
1621        let header = &header[..header_len];
1622        bytes.splice(10..10 + header_len, header.bytes());
1623        let err = decode_mask_npy(&bytes).unwrap_err();
1624        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1625    }
1626
1627    #[test]
1628    fn mask_edf_round_trips_bytes_and_shape() {
1629        // A small 2x3 uint8 mask round-trips through encode -> decode with
1630        // identical shape and data. Note byte values 10 (b'\n') and 32 (b' ')
1631        // appear in the body to prove the trailing-bytes split is not confused
1632        // by header whitespace.
1633        let data: Vec<u8> = vec![0, 10, 32, 250, 254, 255];
1634        let bytes = encode_mask_edf(2, 3, &data);
1635        let (h, w, out) = decode_mask_edf(&bytes).expect("decode");
1636        assert_eq!((h, w), (2, 3));
1637        assert_eq!(out, data);
1638    }
1639
1640    #[test]
1641    fn mask_edf_header_is_512_aligned_and_self_describing() {
1642        let data = vec![7u8; 6];
1643        let bytes = encode_mask_edf(2, 3, &data);
1644        // The header block (everything before the raw body) is 512-aligned.
1645        let body_start = bytes.len() - data.len();
1646        assert_eq!(
1647            body_start % 512,
1648            0,
1649            "header block {body_start} not 512-aligned"
1650        );
1651        let header = std::str::from_utf8(&bytes[..body_start]).expect("ascii header");
1652        assert!(header.starts_with('{'));
1653        assert!(header.contains("DataType = UnsignedByte ;"));
1654        assert!(header.contains("Dim_1 = 3 ;")); // width = fast axis
1655        assert!(header.contains("Dim_2 = 2 ;")); // height = slow axis
1656        assert!(header.contains("Size = 6 ;"));
1657        assert!(header.trim_end().ends_with('}'));
1658        // The raw C-order body follows the aligned header exactly.
1659        assert_eq!(&bytes[body_start..], data.as_slice());
1660    }
1661
1662    #[test]
1663    fn mask_edf_rejects_non_byte_type_and_truncated_body() {
1664        // A float dtype is rejected.
1665        let bytes = encode_mask_edf(1, 1, &[0]);
1666        let header_end = bytes.len() - 1;
1667        let header = std::str::from_utf8(&bytes[..header_end])
1668            .unwrap()
1669            .replace("UnsignedByte", "FloatValue ");
1670        let mut tampered = header.into_bytes();
1671        tampered.push(0);
1672        let err = decode_mask_edf(&tampered).unwrap_err();
1673        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1674
1675        // A body shorter than Dim_1 * Dim_2 is rejected.
1676        let mut short = encode_mask_edf(4, 4, &[1u8; 16]);
1677        short.truncate(short.len() - 8);
1678        let err = decode_mask_edf(&short).unwrap_err();
1679        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1680    }
1681
1682    #[test]
1683    fn mask_tiff_round_trips_bytes_and_shape() {
1684        // A small 2x3 uint8 mask round-trips through the grayscale TIFF codec
1685        // with identical shape and row-major data (full uint8 range included).
1686        let data: Vec<u8> = vec![0, 1, 127, 200, 254, 255];
1687        let bytes = encode_mask_tiff(2, 3, &data).expect("encode");
1688        let (h, w, out) = decode_mask_tiff(&bytes).expect("decode");
1689        assert_eq!((h, w), (2, 3));
1690        assert_eq!(out, data);
1691    }
1692
1693    #[test]
1694    fn mask_tiff_rejects_a_multichannel_image() {
1695        // An RGB (3-channel) TIFF is not a 2D uint8 mask: its sample count is
1696        // 3 × width × height, so decode_mask_tiff must reject it rather than
1697        // silently mis-shape the mask.
1698        use tiff::encoder::{TiffEncoder, colortype::RGB8};
1699        let mut cursor = std::io::Cursor::new(Vec::new());
1700        TiffEncoder::new(&mut cursor)
1701            .unwrap()
1702            .write_image::<RGB8>(2, 2, &[0u8; 12])
1703            .unwrap();
1704        let rgb_tiff = cursor.into_inner();
1705        let err = decode_mask_tiff(&rgb_tiff).unwrap_err();
1706        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1707    }
1708
1709    #[test]
1710    fn mask_msk_round_trips_as_binary_with_odd_width() {
1711        // A width that is not a multiple of 32 exercises the bit-padding
1712        // (width 33 -> num_ints 2 -> 8 bytes/row, 31 trailing pad bits). The
1713        // fit2d format is binary: every non-zero level collapses to 1.
1714        let (h, w) = (2u32, 33u32);
1715        let mut data = vec![0u8; (h * w) as usize];
1716        data[0] = 5; // non-zero level -> bit set -> reads back as 1
1717        data[32] = 200; // last column of row 0 (index 32) -> bit set
1718        data[(w as usize) + 1] = 1; // row 1, column 1
1719        let bytes = encode_mask_msk(h, w, &data);
1720        // Header is 1024 bytes; body is height * ((w+31)/32)*4 = 2*8 = 16 bytes.
1721        assert_eq!(bytes.len(), 1024 + 16);
1722        let (dh, dw, out) = decode_mask_msk(&bytes).expect("decode");
1723        assert_eq!((dh, dw), (h, w));
1724        let mut expected = vec![0u8; (h * w) as usize];
1725        expected[0] = 1;
1726        expected[32] = 1;
1727        expected[(w as usize) + 1] = 1;
1728        assert_eq!(out, expected);
1729    }
1730
1731    #[test]
1732    fn mask_msk_rejects_bad_magic_and_short_header() {
1733        // A buffer shorter than the 1024-byte header, and a full-size buffer
1734        // with the wrong magic, are both rejected (faithful to fabio's
1735        // "Not a fit2d mask file").
1736        assert_eq!(
1737            decode_mask_msk(&[0u8; 16]).unwrap_err().kind(),
1738            std::io::ErrorKind::InvalidData
1739        );
1740        let mut buf = vec![0u8; 1024 + 4];
1741        buf[0] = b'X'; // not 'M'
1742        assert_eq!(
1743            decode_mask_msk(&buf).unwrap_err().kind(),
1744            std::io::ErrorKind::InvalidData
1745        );
1746    }
1747
1748    #[test]
1749    fn mask_msk_matches_fabio_reference_bytes() {
1750        // Byte-for-byte parity with fabio Fit2dMaskImage.save: the reference was
1751        // generated by `fabio.fit2dmaskimage` on the mask [[0,1,0,1,1],[0,0,1,0,0]]
1752        // (h=2, w=5) and produces a 1032-byte file — magic M/A/S/K at bytes
1753        // 0/4/8/12, dim1=5 @16, dim2=2 @20, byte 24 = 1, then the 8-byte
1754        // bit-packed body `1a00000004000000` (row0 cols 1/3/4 -> 0x1A,
1755        // row1 col 2 -> 0x04). Our encoder must reproduce this exactly.
1756        let data: Vec<u8> = vec![0, 1, 0, 1, 1, /**/ 0, 0, 1, 0, 0];
1757        let mut expected = vec![0u8; 1024 + 8];
1758        expected[0] = b'M';
1759        expected[4] = b'A';
1760        expected[8] = b'S';
1761        expected[12] = b'K';
1762        expected[16] = 5; // dim1 = width, LE
1763        expected[20] = 2; // dim2 = height, LE
1764        expected[24] = 1;
1765        expected[1024] = 0x1A;
1766        expected[1028] = 0x04;
1767        assert_eq!(encode_mask_msk(2, 5, &data), expected);
1768        // And the reference bytes decode back to the same binary mask.
1769        let (h, w, out) = decode_mask_msk(&expected).expect("decode");
1770        assert_eq!((h, w), (2, 5));
1771        assert_eq!(out, data);
1772    }
1773
1774    #[test]
1775    fn base64_encode_matches_known_vector() {
1776        // RFC 4648 test vectors.
1777        assert_eq!(base64_encode(b""), "");
1778        assert_eq!(base64_encode(b"f"), "Zg==");
1779        assert_eq!(base64_encode(b"fo"), "Zm8=");
1780        assert_eq!(base64_encode(b"foo"), "Zm9v");
1781        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
1782        assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
1783        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
1784    }
1785
1786    #[test]
1787    fn encode_svg_is_well_formed_with_size_and_png_payload() {
1788        let rgba = [
1789            11, 22, 33, 255, 44, 55, 66, 255, 77, 88, 99, 255, 1, 2, 3, 255,
1790        ];
1791        let svg = encode_svg(&rgba, 2, 2).expect("svg");
1792
1793        // XML declaration and SVG 1.1 DOCTYPE.
1794        assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>"));
1795        assert!(svg.contains("<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\""));
1796        // Root dimensions in px appear on the <svg> element.
1797        assert!(svg.contains("width=\"2\""));
1798        assert!(svg.contains("height=\"2\""));
1799        // The <image> element with a base64 PNG data URI and id.
1800        assert!(svg.contains("<image xlink:href=\"data:image/png;base64,"));
1801        assert!(svg.contains("x=\"0\""));
1802        assert!(svg.contains("y=\"0\""));
1803        assert!(svg.contains("id=\"image\" />"));
1804        assert!(svg.trim_end().ends_with("</svg>"));
1805
1806        // The embedded payload decodes to a valid RGB PNG of the right size,
1807        // matching the input pixels (alpha dropped).
1808        let marker = "base64,";
1809        let start = svg.find(marker).expect("data uri") + marker.len();
1810        let end = svg[start..].find('"').expect("end quote") + start;
1811        let b64 = &svg[start..end];
1812        let png_bytes = base64_decode_for_test(b64);
1813        let decoder = png::Decoder::new(std::io::Cursor::new(&png_bytes));
1814        let mut reader = decoder.read_info().expect("read info");
1815        let mut buf = vec![0u8; reader.output_buffer_size().expect("buffer size")];
1816        let info = reader.next_frame(&mut buf).expect("frame");
1817        assert_eq!(info.width, 2);
1818        assert_eq!(info.height, 2);
1819        assert_eq!(info.color_type, png::ColorType::Rgb);
1820        let expected_rgb = rgba_to_rgb(&rgba, 2, 2);
1821        assert_eq!(&buf[..expected_rgb.len()], expected_rgb.as_slice());
1822    }
1823
1824    /// Minimal base64 decoder for the SVG payload round-trip test.
1825    fn base64_decode_for_test(s: &str) -> Vec<u8> {
1826        fn val(c: u8) -> Option<u8> {
1827            match c {
1828                b'A'..=b'Z' => Some(c - b'A'),
1829                b'a'..=b'z' => Some(c - b'a' + 26),
1830                b'0'..=b'9' => Some(c - b'0' + 52),
1831                b'+' => Some(62),
1832                b'/' => Some(63),
1833                _ => None,
1834            }
1835        }
1836        let mut out = Vec::new();
1837        let mut acc = 0u32;
1838        let mut bits = 0u32;
1839        for &c in s.as_bytes() {
1840            if c == b'=' {
1841                break;
1842            }
1843            let Some(v) = val(c) else { continue };
1844            acc = (acc << 6) | v as u32;
1845            bits += 6;
1846            if bits >= 8 {
1847                bits -= 8;
1848                out.push((acc >> bits) as u8);
1849            }
1850        }
1851        out
1852    }
1853
1854    // --- TIFF ---
1855
1856    /// Minimal little-endian baseline-TIFF reader for tests: returns
1857    /// `(width, height, ifd_entries, pixel_bytes)` where `ifd_entries` maps
1858    /// tag → (type, count, raw 4-byte value/offset field) and `pixel_bytes` is
1859    /// the StripOffsets/StripByteCounts strip.
1860    fn parse_tiff(bytes: &[u8]) -> ParsedTiff {
1861        assert_eq!(&bytes[0..2], b"II", "byte order must be little-endian");
1862        assert_eq!(u16::from_le_bytes([bytes[2], bytes[3]]), 42, "magic 42");
1863        let ifd_off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
1864        let n = u16::from_le_bytes([bytes[ifd_off], bytes[ifd_off + 1]]) as usize;
1865        let mut tags = std::collections::HashMap::new();
1866        for i in 0..n {
1867            let base = ifd_off + 2 + i * 12;
1868            let tag = u16::from_le_bytes([bytes[base], bytes[base + 1]]);
1869            let typ = u16::from_le_bytes([bytes[base + 2], bytes[base + 3]]);
1870            let count = u32::from_le_bytes([
1871                bytes[base + 4],
1872                bytes[base + 5],
1873                bytes[base + 6],
1874                bytes[base + 7],
1875            ]);
1876            let val = [
1877                bytes[base + 8],
1878                bytes[base + 9],
1879                bytes[base + 10],
1880                bytes[base + 11],
1881            ];
1882            tags.insert(tag, (typ, count, val));
1883        }
1884        // The next-IFD pointer must be 0 (single image).
1885        let next_off = ifd_off + 2 + n * 12;
1886        assert_eq!(
1887            u32::from_le_bytes([
1888                bytes[next_off],
1889                bytes[next_off + 1],
1890                bytes[next_off + 2],
1891                bytes[next_off + 3]
1892            ]),
1893            0,
1894            "single-image TIFF: next IFD offset is 0"
1895        );
1896
1897        let width = le_u32(&tags[&256].2);
1898        let height = le_u32(&tags[&257].2);
1899        let strip_off = le_u32(&tags[&273].2) as usize;
1900        let strip_len = le_u32(&tags[&279].2) as usize;
1901        let pixels = bytes[strip_off..strip_off + strip_len].to_vec();
1902        (width, height, tags, pixels)
1903    }
1904
1905    fn le_u32(v: &[u8; 4]) -> u32 {
1906        u32::from_le_bytes(*v)
1907    }
1908
1909    /// Read a SHORT value left-justified in a 4-byte IFD field.
1910    fn le_short(v: &[u8; 4]) -> u16 {
1911        u16::from_le_bytes([v[0], v[1]])
1912    }
1913
1914    /// Read a RATIONAL (num/den) given the byte stream and its out-of-line
1915    /// offset (stored in the IFD value field).
1916    fn read_rational(bytes: &[u8], off: u32) -> (u32, u32) {
1917        let o = off as usize;
1918        let num = u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
1919        let den = u32::from_le_bytes([bytes[o + 4], bytes[o + 5], bytes[o + 6], bytes[o + 7]]);
1920        (num, den)
1921    }
1922
1923    #[test]
1924    fn encode_tiff_header_tags_and_pixels_round_trip() {
1925        // 2×2 RGBA image, distinct pixels.
1926        let rgba = [
1927            10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255,
1928        ];
1929        let tiff = encode_tiff(&rgba, 2, 2, 96);
1930        let (w, h, tags, pixels) = parse_tiff(&tiff);
1931
1932        assert_eq!((w, h), (2, 2));
1933        // Baseline required tags.
1934        assert_eq!(le_short(&tags[&259].2), 1, "Compression = none");
1935        assert_eq!(le_short(&tags[&262].2), 2, "Photometric = RGB");
1936        assert_eq!(le_short(&tags[&277].2), 3, "SamplesPerPixel = 3");
1937        assert_eq!(le_u32(&tags[&278].2), 2, "RowsPerStrip = height");
1938        assert_eq!(le_u32(&tags[&279].2), 2 * 2 * 3, "StripByteCounts = w*h*3");
1939
1940        // BitsPerSample is 3 SHORTs stored out-of-line; verify 8,8,8.
1941        let (typ, count, bits_val) = tags[&258];
1942        assert_eq!(typ, 3);
1943        assert_eq!(count, 3);
1944        let bits_off = le_u32(&bits_val) as usize;
1945        assert_eq!(
1946            &tiff[bits_off..bits_off + 6],
1947            &[8, 0, 8, 0, 8, 0],
1948            "BitsPerSample = 8,8,8"
1949        );
1950
1951        // Pixel bytes round-trip as RGB (alpha dropped).
1952        let expected_rgb = rgba_to_rgb(&rgba, 2, 2);
1953        assert_eq!(pixels, expected_rgb);
1954    }
1955
1956    #[test]
1957    fn encode_tiff_resolution_tags_reflect_dpi() {
1958        let rgba = [1, 2, 3, 255];
1959        let tiff = encode_tiff(&rgba, 1, 1, 300);
1960        let (_, _, tags, _) = parse_tiff(&tiff);
1961
1962        // ResolutionUnit = 2 (inch).
1963        assert_eq!(le_short(&tags[&296].2), 2, "ResolutionUnit = inch");
1964        // XResolution / YResolution are RATIONAL = 300/1.
1965        let xres = read_rational(&tiff, le_u32(&tags[&282].2));
1966        let yres = read_rational(&tiff, le_u32(&tags[&283].2));
1967        assert_eq!(xres, (300, 1), "XResolution = 300 dpi");
1968        assert_eq!(yres, (300, 1), "YResolution = 300 dpi");
1969    }
1970
1971    #[test]
1972    fn encode_tiff_clamps_zero_dpi_to_one() {
1973        // dpi = 0 would write a 0/1 rational; clamp to 1 so the tag is valid.
1974        let rgba = [1, 2, 3, 255];
1975        let tiff = encode_tiff(&rgba, 1, 1, 0);
1976        let (_, _, tags, _) = parse_tiff(&tiff);
1977        assert_eq!(read_rational(&tiff, le_u32(&tags[&282].2)), (1, 1));
1978    }
1979}