use std::fmt;
use std::path::Path;
use egui::{Pos2, Rect};
use egui_wgpu::{RenderState, wgpu};
use crate::core::plot::Plot;
use crate::core::transform::Scale;
use crate::render::backend_wgpu::WgpuResources;
#[derive(Debug)]
pub enum SaveError {
Io(std::io::Error),
Encode(png::EncodingError),
Readback(String),
}
impl fmt::Display for SaveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SaveError::Io(e) => write!(f, "save_graph: writing PNG: {e}"),
SaveError::Encode(e) => write!(f, "save_graph: encoding PNG: {e}"),
SaveError::Readback(e) => write!(f, "save_graph: GPU readback: {e}"),
}
}
}
impl std::error::Error for SaveError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SaveError::Io(e) => Some(e),
SaveError::Encode(e) => Some(e),
SaveError::Readback(_) => None,
}
}
}
impl From<std::io::Error> for SaveError {
fn from(e: std::io::Error) -> Self {
SaveError::Io(e)
}
}
impl From<png::EncodingError> for SaveError {
fn from(e: png::EncodingError) -> Self {
SaveError::Encode(e)
}
}
pub(crate) fn padded_bytes_per_row(width: u32) -> u32 {
let unpadded = 4 * width;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
unpadded.div_ceil(align) * align
}
pub(crate) fn rows_to_rgba8(
mapped: &[u8],
width: u32,
height: u32,
bytes_per_row: u32,
format: wgpu::TextureFormat,
) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let bpr = bytes_per_row as usize;
let row_bytes = w * 4;
let swap = matches!(
format,
wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
);
let mut out = vec![0u8; w * h * 4];
for row in 0..h {
let src = &mapped[row * bpr..row * bpr + row_bytes];
let dst = &mut out[row * row_bytes..(row + 1) * row_bytes];
if swap {
for (s, d) in src.chunks_exact(4).zip(dst.chunks_exact_mut(4)) {
d[0] = s[2];
d[1] = s[1];
d[2] = s[0];
d[3] = s[3];
}
} else {
dst.copy_from_slice(src);
}
}
out
}
pub fn encode_png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, png::EncodingError> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(rgba)?;
}
Ok(out)
}
pub fn rgba_to_rgb(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
let n = (width as usize) * (height as usize);
let mut out = Vec::with_capacity(n * 3);
for px in rgba.chunks_exact(4).take(n) {
out.push(px[0]);
out.push(px[1]);
out.push(px[2]);
}
out
}
pub fn encode_ppm(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
let rgb = rgba_to_rgb(rgba, width, height);
let header = format!("P6\n{width} {height}\n255\n");
let mut out = Vec::with_capacity(header.len() + rgb.len());
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(&rgb);
out
}
pub fn encode_mask_npy(height: u32, width: u32, data: &[u8]) -> Vec<u8> {
const MAGIC: &[u8] = b"\x93NUMPY";
let header =
format!("{{'descr': '|u1', 'fortran_order': False, 'shape': ({height}, {width}), }}");
let unpadded = MAGIC.len() + 2 + 2 + header.len() + 1;
let pad = (64 - (unpadded % 64)) % 64;
let header_len = header.len() + pad + 1; debug_assert!(header_len <= u16::MAX as usize);
let mut out = Vec::with_capacity(unpadded + pad + data.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&[1u8, 0u8]); out.extend_from_slice(&(header_len as u16).to_le_bytes());
out.extend_from_slice(header.as_bytes());
out.extend(std::iter::repeat_n(b' ', pad));
out.push(b'\n');
out.extend_from_slice(data);
out
}
pub fn decode_mask_npy(bytes: &[u8]) -> std::io::Result<(u32, u32, Vec<u8>)> {
use std::io::Read;
let mut r = bytes;
let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
let mut magic = [0u8; 6];
r.read_exact(&mut magic)?;
if &magic != b"\x93NUMPY" {
return Err(invalid("not a .npy file (bad magic)"));
}
let mut version = [0u8; 2];
r.read_exact(&mut version)?;
let header_len = if version[0] >= 2 {
let mut len = [0u8; 4];
r.read_exact(&mut len)?;
u32::from_le_bytes(len) as usize
} else {
let mut len = [0u8; 2];
r.read_exact(&mut len)?;
u16::from_le_bytes(len) as usize
};
let mut header_bytes = vec![0u8; header_len];
r.read_exact(&mut header_bytes)?;
let header =
std::str::from_utf8(&header_bytes).map_err(|_| invalid("npy header is not UTF-8"))?;
let descr =
npy_header_field(header, "descr").ok_or_else(|| invalid("npy header missing 'descr'"))?;
if !matches!(descr.as_str(), "|u1" | "<u1" | ">u1" | "u1") {
return Err(invalid("npy mask must be uint8 ('|u1')"));
}
let fortran = npy_header_field(header, "fortran_order")
.ok_or_else(|| invalid("npy header missing 'fortran_order'"))?;
if fortran != "False" {
return Err(invalid("npy mask must be C-order (fortran_order: False)"));
}
let (height, width) = npy_shape_2d(header)?;
let count = (height as usize) * (width as usize);
let mut data = vec![0u8; count];
r.read_exact(&mut data)?;
Ok((height, width, data))
}
fn npy_header_field(header: &str, key: &str) -> Option<String> {
let needle = format!("'{key}':");
let start = header.find(&needle)? + needle.len();
let rest = &header[start..];
let end = rest.find([',', '}'])?;
let value = rest[..end].trim();
Some(value.trim_matches(['\'', '"']).to_string())
}
fn npy_shape_2d(header: &str) -> std::io::Result<(u32, u32)> {
let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
let start = header
.find("'shape':")
.ok_or_else(|| invalid("npy header missing 'shape'"))?
+ "'shape':".len();
let rest = &header[start..];
let open = rest.find('(').ok_or_else(|| invalid("malformed shape"))?;
let close = rest.find(')').ok_or_else(|| invalid("malformed shape"))?;
let dims: Vec<u32> = rest[open + 1..close]
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.parse::<u32>())
.collect::<Result<_, _>>()
.map_err(|_| invalid("non-integer shape dimension"))?;
if dims.len() != 2 {
return Err(invalid("npy mask must be 2D"));
}
Ok((dims[0], dims[1]))
}
pub fn encode_mask_edf(height: u32, width: u32, data: &[u8]) -> Vec<u8> {
const BLOCK: usize = 512;
let size = (height as usize) * (width as usize);
let mut header = String::from("{\n");
header.push_str("HeaderID = EH:000001:000000:000000 ;\n");
header.push_str("Image = 1 ;\n");
header.push_str("ByteOrder = LowByteFirst ;\n");
header.push_str("DataType = UnsignedByte ;\n");
header.push_str(&format!("Dim_1 = {width} ;\n"));
header.push_str(&format!("Dim_2 = {height} ;\n"));
header.push_str(&format!("Size = {size} ;\n"));
let unpadded = header.len() + 2; let pad = (BLOCK - (unpadded % BLOCK)) % BLOCK;
header.extend(std::iter::repeat_n(' ', pad));
header.push_str("}\n");
let mut out = Vec::with_capacity(header.len() + data.len());
out.extend_from_slice(header.as_bytes());
out.extend_from_slice(data);
out
}
pub fn decode_mask_edf(bytes: &[u8]) -> std::io::Result<(u32, u32, Vec<u8>)> {
let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
let open = bytes
.iter()
.position(|&b| b == b'{')
.ok_or_else(|| invalid("not an EDF file (no '{')"))?;
let close = bytes[open..]
.iter()
.position(|&b| b == b'}')
.ok_or_else(|| invalid("EDF header not terminated ('}' missing)"))?
+ open;
let header = std::str::from_utf8(&bytes[open + 1..close])
.map_err(|_| invalid("EDF header is not UTF-8"))?;
let data_type = edf_header_field(header, "DataType")
.ok_or_else(|| invalid("EDF header missing 'DataType'"))?;
if data_type != "UnsignedByte" {
return Err(invalid("EDF mask must be UnsignedByte"));
}
let width: u32 = edf_header_field(header, "Dim_1")
.ok_or_else(|| invalid("EDF header missing 'Dim_1'"))?
.parse()
.map_err(|_| invalid("EDF 'Dim_1' is not an integer"))?;
let height: u32 = edf_header_field(header, "Dim_2")
.ok_or_else(|| invalid("EDF header missing 'Dim_2'"))?
.parse()
.map_err(|_| invalid("EDF 'Dim_2' is not an integer"))?;
let mut data_start = close + 1;
if bytes.get(data_start) == Some(&b'\n') {
data_start += 1;
}
let count = (height as usize) * (width as usize);
if bytes.len().saturating_sub(data_start) < count {
return Err(invalid("EDF body shorter than Dim_1 * Dim_2"));
}
let data = bytes[data_start..data_start + count].to_vec();
Ok((height, width, data))
}
fn edf_header_field(header: &str, key: &str) -> Option<String> {
header.split(';').find_map(|entry| {
let (k, v) = entry.split_once('=')?;
(k.trim() == key).then(|| v.trim().to_string())
})
}
pub fn encode_mask_tiff(height: u32, width: u32, data: &[u8]) -> std::io::Result<Vec<u8>> {
use tiff::encoder::{TiffEncoder, colortype::Gray8};
let to_io = |e: tiff::TiffError| std::io::Error::new(std::io::ErrorKind::InvalidData, e);
let mut cursor = std::io::Cursor::new(Vec::new());
let mut encoder = TiffEncoder::new(&mut cursor).map_err(to_io)?;
encoder
.write_image::<Gray8>(width, height, data)
.map_err(to_io)?;
Ok(cursor.into_inner())
}
pub fn decode_mask_tiff(bytes: &[u8]) -> std::io::Result<(u32, u32, Vec<u8>)> {
use tiff::decoder::{Decoder, DecodingResult};
let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
let to_io = |e: tiff::TiffError| std::io::Error::new(std::io::ErrorKind::InvalidData, e);
let mut decoder = Decoder::new(std::io::Cursor::new(bytes)).map_err(to_io)?;
let (width, height) = decoder.dimensions().map_err(to_io)?;
let data = match decoder.read_image().map_err(to_io)? {
DecodingResult::U8(v) => v,
_ => return Err(invalid("TIFF mask must be 8-bit (uint8) samples")),
};
let count = (width as usize) * (height as usize);
if data.len() != count {
return Err(invalid(
"TIFF mask must be single-channel (width * height uint8 samples)",
));
}
Ok((height, width, data))
}
pub fn write_mask_hdf5(
path: &std::path::Path,
data_path: &str,
height: u32,
width: u32,
data: &[u8],
) -> std::io::Result<()> {
use rust_hdf5::H5File;
let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
let file = if path.exists() {
H5File::open_rw(path).map_err(to_io)?
} else {
H5File::create(path).map_err(to_io)?
};
if file.dataset_names().iter().any(|n| n == data_path) {
file.delete_dataset(data_path).map_err(to_io)?;
}
let ds = file
.new_dataset::<u8>()
.shape([height as usize, width as usize])
.create(data_path)
.map_err(to_io)?;
ds.write_raw(data).map_err(to_io)?;
file.close().map_err(to_io)?;
Ok(())
}
pub fn list_mask_datasets_hdf5(path: &std::path::Path) -> std::io::Result<Vec<String>> {
use rust_hdf5::H5File;
let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
let file = H5File::open(path).map_err(to_io)?;
let mut out = Vec::new();
for name in file.dataset_names() {
let ds = file.dataset(&name).map_err(to_io)?;
if ds.ndims() == 2 {
out.push(name);
}
}
out.sort();
Ok(out)
}
pub fn read_mask_hdf5(
path: &std::path::Path,
data_path: &str,
) -> std::io::Result<(u32, u32, Vec<u8>)> {
use rust_hdf5::H5File;
let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
let file = H5File::open(path).map_err(to_io)?;
let ds = file.dataset(data_path).map_err(to_io)?;
if ds.ndims() != 2 {
return Err(invalid("HDF5 mask dataset must be 2-dimensional"));
}
let shape = ds.shape();
let (height, width) = (shape[0], shape[1]);
let data: Vec<u8> = ds.read_raw().map_err(to_io)?;
if data.len() != height * width {
return Err(invalid(
"HDF5 mask body length does not match its (height, width) shape",
));
}
Ok((height as u32, width as u32, data))
}
pub fn read_mask_hdf5_auto(path: &std::path::Path) -> std::io::Result<(u32, u32, Vec<u8>)> {
let datasets = list_mask_datasets_hdf5(path)?;
let first = datasets.first().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"HDF5 file contains no 2D dataset to load a mask from",
)
})?;
read_mask_hdf5(path, first)
}
fn read_hdf5_elements_as_f32(
ds: &rust_hdf5::H5Dataset,
elements: usize,
) -> std::io::Result<Vec<f32>> {
let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
let invalid = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
let data: Vec<f32> = match ds.element_size() {
4 => ds.read_raw::<f32>().map_err(to_io)?,
8 => ds
.read_raw::<f64>()
.map_err(to_io)?
.into_iter()
.map(|x| x as f32)
.collect(),
n => {
return Err(invalid(format!(
"unsupported HDF5 element size {n} (only 4-byte f32 / 8-byte f64 images are read)"
)));
}
};
if data.len() != elements {
return Err(invalid(format!(
"HDF5 image body length {} does not match its shape ({elements} elements)",
data.len()
)));
}
Ok(data)
}
pub fn read_image_hdf5(
path: &std::path::Path,
data_path: &str,
) -> std::io::Result<(u32, u32, Vec<f32>)> {
use rust_hdf5::H5File;
let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
let file = H5File::open(path).map_err(to_io)?;
let ds = file.dataset(data_path).map_err(to_io)?;
if ds.ndims() != 2 {
return Err(invalid("HDF5 image dataset must be 2-dimensional"));
}
let shape = ds.shape();
let (height, width) = (shape[0], shape[1]);
let data = read_hdf5_elements_as_f32(&ds, height.saturating_mul(width))?;
Ok((height as u32, width as u32, data))
}
pub fn read_image_hdf5_slice(
path: &std::path::Path,
data_path: &str,
index: usize,
) -> std::io::Result<(u32, u32, Vec<f32>)> {
use rust_hdf5::H5File;
let to_io = |e: rust_hdf5::Hdf5Error| std::io::Error::other(e.to_string());
let invalid = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
let file = H5File::open(path).map_err(to_io)?;
let ds = file.dataset(data_path).map_err(to_io)?;
if ds.ndims() != 3 {
return Err(invalid(
"HDF5 image-stack dataset must be 3-dimensional".into(),
));
}
let shape = ds.shape();
let (n, height, width) = (shape[0], shape[1], shape[2]);
if index >= n {
return Err(invalid(format!(
"HDF5 slice index {index} out of range (stack has {n} frames)"
)));
}
let elements = height.saturating_mul(width);
let data: Vec<f32> = match ds.element_size() {
4 => ds
.read_slice::<f32>(&[index, 0, 0], &[1, height, width])
.map_err(to_io)?,
8 => ds
.read_slice::<f64>(&[index, 0, 0], &[1, height, width])
.map_err(to_io)?
.into_iter()
.map(|x| x as f32)
.collect(),
sz => {
return Err(invalid(format!(
"unsupported HDF5 element size {sz} (only 4-byte f32 / 8-byte f64 images are read)"
)));
}
};
if data.len() != elements {
return Err(invalid(format!(
"HDF5 slice body length {} does not match its shape ({elements} elements)",
data.len()
)));
}
Ok((height as u32, width as u32, data))
}
pub fn encode_mask_msk(height: u32, width: u32, data: &[u8]) -> Vec<u8> {
let (h, w) = (height as usize, width as usize);
let num_ints = w.div_ceil(32);
let bytes_per_row = num_ints * 4;
let mut out = vec![0u8; 1024 + h * bytes_per_row];
out[0] = b'M';
out[4] = b'A';
out[8] = b'S';
out[12] = b'K';
out[16..20].copy_from_slice(&width.to_le_bytes());
out[20..24].copy_from_slice(&height.to_le_bytes());
out[24] = 1;
for y in 0..h {
let row = 1024 + y * bytes_per_row;
for x in 0..w {
if data[y * w + x] != 0 {
out[row + (x >> 3)] |= 1 << (x & 7);
}
}
}
out
}
pub fn decode_mask_msk(bytes: &[u8]) -> std::io::Result<(u32, u32, Vec<u8>)> {
let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
if bytes.len() < 1024 {
return Err(invalid(
"fit2d mask file is shorter than its 1024-byte header",
));
}
if bytes[0] != b'M' || bytes[4] != b'A' || bytes[8] != b'S' || bytes[12] != b'K' {
return Err(invalid("Not a fit2d mask file (bad MASK magic)"));
}
let width = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let height = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
let (h, w) = (height as usize, width as usize);
let num_ints = w.div_ceil(32);
let bytes_per_row = num_ints * 4;
let total = h * bytes_per_row;
if bytes.len() < 1024 + total {
return Err(invalid(
"fit2d mask body is shorter than its (height, width) shape requires",
));
}
let mut data = vec![0u8; w * h];
for y in 0..h {
let row = 1024 + y * bytes_per_row;
for x in 0..w {
let bit = (bytes[row + (x >> 3)] >> (x & 7)) & 1;
data[y * w + x] = bit;
}
}
Ok((height, width, data))
}
const BASE64_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(BASE64_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
out.push(BASE64_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
out.push(BASE64_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(BASE64_ALPHABET[(n & 0x3F) as usize] as char);
} else {
out.push('=');
}
}
out
}
fn encode_rgb_png(rgb: &[u8], width: u32, height: u32) -> Result<Vec<u8>, png::EncodingError> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(rgb)?;
}
Ok(out)
}
pub fn encode_svg(rgba: &[u8], width: u32, height: u32) -> Result<String, png::EncodingError> {
let rgb = rgba_to_rgb(rgba, width, height);
let png = encode_rgb_png(&rgb, width, height)?;
let b64 = base64_encode(&png);
let mut s = String::new();
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
s.push_str("<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n");
s.push_str(" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n");
s.push_str("<svg xmlns:xlink=\"http://www.w3.org/1999/xlink\"\n");
s.push_str(" xmlns=\"http://www.w3.org/2000/svg\"\n");
s.push_str(" version=\"1.1\"\n");
s.push_str(&format!(" width=\"{width}\"\n"));
s.push_str(&format!(" height=\"{height}\">\n"));
s.push_str(" <image xlink:href=\"data:image/png;base64,");
s.push_str(&b64);
s.push_str("\"\n");
s.push_str(" x=\"0\"\n");
s.push_str(" y=\"0\"\n");
s.push_str(&format!(" width=\"{width}\"\n"));
s.push_str(&format!(" height=\"{height}\"\n"));
s.push_str(" id=\"image\" />\n");
s.push_str("</svg>");
Ok(s)
}
pub fn encode_tiff(rgba: &[u8], width: u32, height: u32, dpi: u32) -> Vec<u8> {
let rgb = rgba_to_rgb(rgba, width, height);
let dpi = dpi.max(1);
const N_ENTRIES: u16 = 12;
let ifd_start: u32 = 8;
let ifd_len: u32 = 2 + 12 * (N_ENTRIES as u32) + 4;
let after_ifd: u32 = ifd_start + ifd_len;
let bits_offset: u32 = after_ifd;
let xres_offset: u32 = bits_offset + 6;
let yres_offset: u32 = xres_offset + 8;
let strip_offset: u32 = yres_offset + 8;
let strip_byte_count: u32 = width * height * 3;
let mut out: Vec<u8> = Vec::with_capacity(strip_offset as usize + rgb.len());
out.extend_from_slice(b"II"); out.extend_from_slice(&42u16.to_le_bytes()); out.extend_from_slice(&ifd_start.to_le_bytes());
out.extend_from_slice(&N_ENTRIES.to_le_bytes());
let mut entry = |tag: u16, typ: u16, count: u32, value_or_offset: u32, is_short: bool| {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&typ.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
if is_short {
out.extend_from_slice(&(value_or_offset as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
} else {
out.extend_from_slice(&value_or_offset.to_le_bytes());
}
};
entry(256, 4, 1, width, false); entry(257, 4, 1, height, false); entry(258, 3, 3, bits_offset, false); entry(259, 3, 1, 1, true); entry(262, 3, 1, 2, true); entry(273, 4, 1, strip_offset, false); entry(277, 3, 1, 3, true); entry(278, 4, 1, height, false); entry(279, 4, 1, strip_byte_count, false); entry(282, 5, 1, xres_offset, false); entry(283, 5, 1, yres_offset, false); entry(296, 3, 1, 2, true);
out.extend_from_slice(&0u32.to_le_bytes());
out.extend_from_slice(&8u16.to_le_bytes());
out.extend_from_slice(&8u16.to_le_bytes());
out.extend_from_slice(&8u16.to_le_bytes());
out.extend_from_slice(&dpi.to_le_bytes());
out.extend_from_slice(&1u32.to_le_bytes());
out.extend_from_slice(&dpi.to_le_bytes());
out.extend_from_slice(&1u32.to_le_bytes());
debug_assert_eq!(out.len() as u32, strip_offset);
out.extend_from_slice(&rgb);
out
}
const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";
fn push_ascii_hex(s: &mut String, data: &[u8]) {
for (i, &b) in data.iter().enumerate() {
s.push(HEX_DIGITS[(b >> 4) as usize] as char);
s.push(HEX_DIGITS[(b & 0x0F) as usize] as char);
if (i + 1) % 40 == 0 {
s.push('\n');
}
}
}
pub fn encode_eps(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
let rgb = rgba_to_rgb(rgba, width, height);
let mut s = String::with_capacity(200 + rgb.len() * 2);
s.push_str("%!PS-Adobe-3.0 EPSF-3.0\n");
s.push_str(&format!("%%BoundingBox: 0 0 {width} {height}\n"));
s.push_str("%%EndComments\n");
s.push_str("gsave\n");
s.push_str(&format!("{width} {height} scale\n"));
s.push_str(&format!("/picstr {} string def\n", width as usize * 3));
s.push_str(&format!(
"{width} {height} 8 [ {width} 0 0 -{height} 0 {height} ]\n"
));
s.push_str("{ currentfile picstr readhexstring pop } false 3 colorimage\n");
push_ascii_hex(&mut s, &rgb);
s.push('\n');
s.push_str("grestore\nshowpage\n");
s.push_str("%%EOF\n");
s.into_bytes()
}
pub fn encode_pdf(rgba: &[u8], width: u32, height: u32) -> Vec<u8> {
let rgb = rgba_to_rgb(rgba, width, height);
let mut hex = String::with_capacity(rgb.len() * 2 + 1);
push_ascii_hex(&mut hex, &rgb);
hex.push('>');
let img_len = hex.len();
let content = format!("q {width} 0 0 {height} 0 0 cm /Im0 Do Q");
let content_len = content.len();
let mut out: Vec<u8> = Vec::with_capacity(img_len + 600);
let mut offsets = [0usize; 6];
out.extend_from_slice(b"%PDF-1.4\n");
offsets[1] = out.len();
out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets[2] = out.len();
out.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
offsets[3] = out.len();
out.extend_from_slice(
format!(
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] \
/Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n"
)
.as_bytes(),
);
offsets[4] = out.len();
out.extend_from_slice(
format!(
"4 0 obj\n<< /Type /XObject /Subtype /Image /Width {width} /Height {height} \
/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode \
/Length {img_len} >>\nstream\n"
)
.as_bytes(),
);
out.extend_from_slice(hex.as_bytes());
out.extend_from_slice(b"\nendstream\nendobj\n");
offsets[5] = out.len();
out.extend_from_slice(format!("5 0 obj\n<< /Length {content_len} >>\nstream\n").as_bytes());
out.extend_from_slice(content.as_bytes());
out.extend_from_slice(b"\nendstream\nendobj\n");
let xref_offset = out.len();
out.extend_from_slice(b"xref\n0 6\n");
out.extend_from_slice(b"0000000000 65535 f \n");
for &off in &offsets[1..6] {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes(),
);
out
}
fn axis_log_flags(t: &crate::core::transform::Transform) -> [f32; 2] {
[
f32::from(t.x.scale == Scale::Log10),
f32::from(t.y.scale == Scale::Log10),
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SaveFormat {
Png,
Ppm,
Svg,
Tiff,
Jpeg,
Eps,
Pdf,
}
impl SaveFormat {
pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_ascii_lowercase().as_str() {
"png" => Some(SaveFormat::Png),
"ppm" => Some(SaveFormat::Ppm),
"svg" => Some(SaveFormat::Svg),
"tif" | "tiff" => Some(SaveFormat::Tiff),
"jpg" | "jpeg" => Some(SaveFormat::Jpeg),
"eps" => Some(SaveFormat::Eps),
"pdf" => Some(SaveFormat::Pdf),
_ => None,
}
}
pub fn from_path(path: &Path) -> Option<Self> {
path.extension()
.and_then(|e| e.to_str())
.and_then(Self::from_extension)
}
}
pub fn render_plot_rgba(
render_state: &RenderState,
plot: &Plot,
size: (u32, u32),
) -> Result<Vec<u8>, SaveError> {
let (w, h) = size;
if w == 0 || h == 0 {
return Err(SaveError::Readback("zero-size target".into()));
}
let area = Rect::from_min_size(Pos2::ZERO, egui::vec2(w as f32, h as f32));
let transform = plot.transform(area);
let transform_right = plot.transform_y2(area);
let ortho_left = transform.ortho_matrix();
let axis_log_left = axis_log_flags(&transform);
let (ortho_right, axis_log_right) = match &transform_right {
Some(t) => (t.ortho_matrix(), axis_log_flags(t)),
None => (ortho_left, axis_log_left),
};
let mut ortho_extra = Vec::with_capacity(plot.extra.len());
let mut axis_log_extra = Vec::with_capacity(plot.extra.len());
for i in 0..plot.extra.len() {
match plot.transform_extra(i, area) {
Some(t) => {
ortho_extra.push(t.ortho_matrix());
axis_log_extra.push(axis_log_flags(&t));
}
None => {
ortho_extra.push(ortho_left);
axis_log_extra.push(axis_log_left);
}
}
}
let bg = egui::Rgba::from(plot.data_background).to_array();
let renderer = render_state.renderer.read();
let res: &WgpuResources = renderer
.callback_resources
.get()
.expect("WgpuResources not installed — call rsplot::install() first");
res.render_to_rgba(
&render_state.device,
&render_state.queue,
render_state.target_format,
plot.id,
size,
bg,
ortho_left,
axis_log_left,
ortho_right,
axis_log_right,
&ortho_extra,
&axis_log_extra,
)
}
pub fn save_graph(
render_state: &RenderState,
plot: &Plot,
size: (u32, u32),
path: impl AsRef<Path>,
) -> Result<(), SaveError> {
let (w, h) = size;
let rgba = render_plot_rgba(render_state, plot, size)?;
let png = encode_png(&rgba, w, h)?;
std::fs::write(path, png)?;
Ok(())
}
pub fn save_graph_with_format(
render_state: &RenderState,
plot: &Plot,
size: (u32, u32),
path: impl AsRef<Path>,
format: SaveFormat,
dpi: u32,
) -> Result<(), SaveError> {
let (w, h) = size;
let rgba = render_plot_rgba(render_state, plot, size)?;
match format {
SaveFormat::Png => {
let bytes = encode_png(&rgba, w, h)?;
std::fs::write(path, bytes)?;
}
SaveFormat::Ppm => {
let bytes = encode_ppm(&rgba, w, h);
std::fs::write(path, bytes)?;
}
SaveFormat::Svg => {
let svg = encode_svg(&rgba, w, h)?;
std::fs::write(path, svg)?;
}
SaveFormat::Tiff => {
let bytes = encode_tiff(&rgba, w, h, dpi);
std::fs::write(path, bytes)?;
}
SaveFormat::Jpeg => {
let bytes = crate::render::jpeg::encode_jpeg(&rgba, w, h, dpi);
std::fs::write(path, bytes)?;
}
SaveFormat::Eps => {
let bytes = encode_eps(&rgba, w, h);
std::fs::write(path, bytes)?;
}
SaveFormat::Pdf => {
let bytes = encode_pdf(&rgba, w, h);
std::fs::write(path, bytes)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
type IfdEntry = (u16, u32, [u8; 4]);
type IfdTags = std::collections::HashMap<u16, IfdEntry>;
type ParsedTiff = (u32, u32, IfdTags, Vec<u8>);
#[test]
fn bytes_per_row_rounds_up_to_256() {
assert_eq!(padded_bytes_per_row(1), 256); assert_eq!(padded_bytes_per_row(64), 256); assert_eq!(padded_bytes_per_row(65), 512); assert_eq!(padded_bytes_per_row(100), 512); }
#[test]
fn rows_to_rgba8_unpads_and_passes_rgba_through() {
let bpr = padded_bytes_per_row(1);
let mut mapped = vec![0u8; (bpr as usize) * 2];
mapped[0..4].copy_from_slice(&[10, 20, 30, 40]); mapped[bpr as usize..bpr as usize + 4].copy_from_slice(&[50, 60, 70, 80]); let out = rows_to_rgba8(&mapped, 1, 2, bpr, wgpu::TextureFormat::Rgba8UnormSrgb);
assert_eq!(out, vec![10, 20, 30, 40, 50, 60, 70, 80]);
}
#[test]
fn rows_to_rgba8_swaps_bgra_to_rgba() {
let bpr = padded_bytes_per_row(1);
let mut mapped = vec![0u8; bpr as usize];
mapped[0..4].copy_from_slice(&[30, 20, 10, 40]); let out = rows_to_rgba8(&mapped, 1, 1, bpr, wgpu::TextureFormat::Bgra8UnormSrgb);
assert_eq!(out, vec![10, 20, 30, 40]); }
fn temp_h5(tag: &str) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("rsplot_image_h5_{}_{}.h5", tag, std::process::id()));
let _ = std::fs::remove_file(&path);
path
}
fn seed_dataset<T: rust_hdf5::types::H5Type>(
path: &std::path::Path,
name: &str,
shape: &[usize],
data: &[T],
) {
use rust_hdf5::H5File;
let file = if path.exists() {
H5File::open_rw(path).unwrap()
} else {
H5File::create(path).unwrap()
};
let ds = file.new_dataset::<T>().shape(shape).create(name).unwrap();
ds.write_raw(data).unwrap();
file.close().unwrap();
}
#[test]
fn read_image_hdf5_roundtrips_f32() {
let path = temp_h5("f32_2d");
seed_dataset(&path, "/img", &[2, 3], &[0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0]);
let (h, w, data) = read_image_hdf5(&path, "/img").expect("read 2D f32");
assert_eq!((h, w), (2, 3));
assert_eq!(data, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_image_hdf5_casts_f64_to_f32() {
let path = temp_h5("f64_2d");
seed_dataset(&path, "/img", &[1, 2], &[1.5f64, 2.5]);
let (h, w, data) = read_image_hdf5(&path, "/img").expect("read 2D f64");
assert_eq!((h, w), (1, 2));
assert_eq!(data, vec![1.5f32, 2.5]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_image_hdf5_rejects_non_2d() {
let path = temp_h5("rank");
seed_dataset(&path, "/vec", &[4], &[0.0f32, 1.0, 2.0, 3.0]);
assert!(read_image_hdf5(&path, "/vec").is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_image_hdf5_slice_reads_one_frame() {
let path = temp_h5("f32_3d");
seed_dataset(
&path,
"/stack",
&[2, 2, 2],
&[0.0f32, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0],
);
let (h, w, f0) = read_image_hdf5_slice(&path, "/stack", 0).expect("slice 0");
assert_eq!((h, w), (2, 2));
assert_eq!(f0, vec![0.0, 1.0, 2.0, 3.0]);
let (_, _, f1) = read_image_hdf5_slice(&path, "/stack", 1).expect("slice 1");
assert_eq!(f1, vec![10.0, 11.0, 12.0, 13.0]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_image_hdf5_slice_rejects_oob_and_non_3d() {
let path = temp_h5("slice_reject");
seed_dataset(&path, "/stack", &[2, 1, 1], &[0.0f32, 1.0]);
assert!(read_image_hdf5_slice(&path, "/stack", 2).is_err());
seed_dataset(&path, "/img2d", &[1, 2], &[0.0f32, 1.0]);
assert!(read_image_hdf5_slice(&path, "/img2d", 0).is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn encode_png_round_trips() {
let rgba: Vec<u8> = (0..16).map(|i| i as u8 * 16).collect();
let png = encode_png(&rgba, 2, 2).expect("encode");
let decoder = png::Decoder::new(std::io::Cursor::new(&png));
let mut reader = decoder.read_info().expect("read info");
let mut buf = vec![0u8; reader.output_buffer_size().expect("buffer size")];
let info = reader.next_frame(&mut buf).expect("frame");
assert_eq!(info.width, 2);
assert_eq!(info.height, 2);
assert_eq!(info.color_type, png::ColorType::Rgba);
assert_eq!(&buf[..rgba.len()], rgba.as_slice());
}
#[test]
fn save_format_from_extension_maps_silx_raster_formats() {
assert_eq!(SaveFormat::from_extension("png"), Some(SaveFormat::Png));
assert_eq!(SaveFormat::from_extension("PNG"), Some(SaveFormat::Png));
assert_eq!(SaveFormat::from_extension("ppm"), Some(SaveFormat::Ppm));
assert_eq!(SaveFormat::from_extension("svg"), Some(SaveFormat::Svg));
assert_eq!(SaveFormat::from_extension("tif"), Some(SaveFormat::Tiff));
assert_eq!(SaveFormat::from_extension("TIFF"), Some(SaveFormat::Tiff));
assert_eq!(SaveFormat::from_extension("eps"), Some(SaveFormat::Eps));
assert_eq!(SaveFormat::from_extension("EPS"), Some(SaveFormat::Eps));
assert_eq!(SaveFormat::from_extension("pdf"), Some(SaveFormat::Pdf));
assert_eq!(SaveFormat::from_extension("PDF"), Some(SaveFormat::Pdf));
assert_eq!(SaveFormat::from_extension("jpg"), Some(SaveFormat::Jpeg));
assert_eq!(SaveFormat::from_extension("JPEG"), Some(SaveFormat::Jpeg));
}
#[test]
fn save_format_rejects_still_unsupported_and_unknown_extensions() {
assert_eq!(SaveFormat::from_extension("ps"), None);
assert_eq!(SaveFormat::from_extension("bmp"), None);
assert_eq!(SaveFormat::from_extension(""), None);
}
#[test]
fn save_format_from_path_uses_extension() {
use std::path::Path;
assert_eq!(
SaveFormat::from_path(Path::new("/tmp/out.tiff")),
Some(SaveFormat::Tiff)
);
assert_eq!(SaveFormat::from_path(Path::new("/tmp/noext")), None);
}
#[test]
fn rgba_to_rgb_drops_alpha() {
let rgba = [10, 20, 30, 99, 40, 50, 60, 88];
let rgb = rgba_to_rgb(&rgba, 2, 1);
assert_eq!(rgb, vec![10, 20, 30, 40, 50, 60]);
}
#[test]
fn encode_ppm_header_and_pixels_round_trip() {
let rgba = [1, 2, 3, 255, 4, 5, 6, 255];
let ppm = encode_ppm(&rgba, 2, 1);
let header = b"P6\n2 1\n255\n";
assert_eq!(&ppm[..header.len()], header);
assert_eq!(&ppm[header.len()..], &[1, 2, 3, 4, 5, 6]);
assert_eq!(ppm.len(), header.len() + 6);
}
#[test]
fn encode_eps_is_well_formed_and_hex_body_round_trips() {
let rgba = [
10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255, ];
let eps = encode_eps(&rgba, 2, 2);
let text = std::str::from_utf8(&eps).expect("EPS is ASCII");
assert!(text.starts_with("%!PS-Adobe-3.0 EPSF-3.0\n"));
assert!(text.contains("%%BoundingBox: 0 0 2 2\n"));
assert!(text.contains("2 2 8 [ 2 0 0 -2 0 2 ]"));
assert!(text.contains("false 3 colorimage"));
assert!(text.trim_end().ends_with("%%EOF"));
let body = text
.split("colorimage\n")
.nth(1)
.expect("body after colorimage")
.split("\ngrestore")
.next()
.expect("hex before grestore");
let hex: String = body.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let decoded: Vec<u8> = hex
.as_bytes()
.chunks_exact(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect();
assert_eq!(decoded, rgba_to_rgb(&rgba, 2, 2));
}
#[test]
fn encode_pdf_is_well_formed_and_xref_offsets_point_at_objects() {
let rgba = [
10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255, ];
let pdf = encode_pdf(&rgba, 2, 2);
let text = std::str::from_utf8(&pdf).expect("PDF body is ASCII here");
assert!(text.starts_with("%PDF-1.4\n"));
assert!(text.contains("/Type /Catalog"));
assert!(text.contains("/MediaBox [0 0 2 2]"));
assert!(text.contains("/Subtype /Image /Width 2 /Height 2"));
assert!(text.contains("/ColorSpace /DeviceRGB"));
assert!(text.contains("/Filter /ASCIIHexDecode"));
assert!(text.trim_end().ends_with("%%EOF"));
let sx = text.rfind("startxref\n").expect("startxref");
let after = &text[sx + "startxref\n".len()..];
let xref_off: usize = after
.lines()
.next()
.unwrap()
.trim()
.parse()
.expect("xref offset int");
assert!(
text[xref_off..].starts_with("xref\n"),
"startxref must point at xref"
);
let stream_at = text.find("/ASCIIHexDecode").expect("image dict");
let body = &text[stream_at..];
let body = &body[body.find("stream\n").expect("stream kw") + "stream\n".len()..];
let hex = &body[..body.find('>').expect("hex EOD marker")];
let hex: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let decoded: Vec<u8> = hex
.as_bytes()
.chunks_exact(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect();
assert_eq!(decoded, rgba_to_rgb(&rgba, 2, 2));
}
#[test]
fn mask_npy_round_trips_bytes_and_shape() {
let data: Vec<u8> = vec![0, 1, 2, 250, 254, 255];
let bytes = encode_mask_npy(2, 3, &data);
let (h, w, out) = decode_mask_npy(&bytes).expect("decode");
assert_eq!((h, w), (2, 3));
assert_eq!(out, data);
}
#[test]
fn mask_npy_header_is_valid_v1_format() {
let data = vec![7u8; 4];
let bytes = encode_mask_npy(2, 2, &data);
assert_eq!(&bytes[0..6], b"\x93NUMPY");
assert_eq!(&bytes[6..8], &[1, 0]);
let header_len = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
let preamble = 10 + header_len;
assert_eq!(preamble % 64, 0, "preamble {preamble} not 64-aligned");
let header = std::str::from_utf8(&bytes[10..preamble]).expect("ascii header");
assert!(header.contains("'descr': '|u1'"));
assert!(header.contains("'fortran_order': False"));
assert!(header.contains("'shape': (2, 2)"));
assert!(header.ends_with('\n'));
assert_eq!(&bytes[preamble..], data.as_slice());
}
#[test]
fn mask_npy_rejects_bad_magic_and_non_uint8() {
let err = decode_mask_npy(b"not-a-npy-file-at-all").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
let mut bytes = encode_mask_npy(1, 1, &[0]);
let header_len = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
let header = std::str::from_utf8(&bytes[10..10 + header_len])
.unwrap()
.replace("|u1", "<f8");
bytes.splice(10..10 + header_len, header.bytes());
let err = decode_mask_npy(&bytes).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn mask_npy_rejects_non_2d_shape() {
let mut bytes = encode_mask_npy(1, 1, &[0]);
let header_len = u16::from_le_bytes([bytes[8], bytes[9]]) as usize;
let header = std::str::from_utf8(&bytes[10..10 + header_len])
.unwrap()
.replace("(1, 1)", "(1, 1, 1)");
let mut header = header;
while header.len() < header_len {
header.insert(header.len() - 1, ' ');
}
let header = &header[..header_len];
bytes.splice(10..10 + header_len, header.bytes());
let err = decode_mask_npy(&bytes).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn mask_edf_round_trips_bytes_and_shape() {
let data: Vec<u8> = vec![0, 10, 32, 250, 254, 255];
let bytes = encode_mask_edf(2, 3, &data);
let (h, w, out) = decode_mask_edf(&bytes).expect("decode");
assert_eq!((h, w), (2, 3));
assert_eq!(out, data);
}
#[test]
fn mask_edf_header_is_512_aligned_and_self_describing() {
let data = vec![7u8; 6];
let bytes = encode_mask_edf(2, 3, &data);
let body_start = bytes.len() - data.len();
assert_eq!(
body_start % 512,
0,
"header block {body_start} not 512-aligned"
);
let header = std::str::from_utf8(&bytes[..body_start]).expect("ascii header");
assert!(header.starts_with('{'));
assert!(header.contains("DataType = UnsignedByte ;"));
assert!(header.contains("Dim_1 = 3 ;")); assert!(header.contains("Dim_2 = 2 ;")); assert!(header.contains("Size = 6 ;"));
assert!(header.trim_end().ends_with('}'));
assert_eq!(&bytes[body_start..], data.as_slice());
}
#[test]
fn mask_edf_rejects_non_byte_type_and_truncated_body() {
let bytes = encode_mask_edf(1, 1, &[0]);
let header_end = bytes.len() - 1;
let header = std::str::from_utf8(&bytes[..header_end])
.unwrap()
.replace("UnsignedByte", "FloatValue ");
let mut tampered = header.into_bytes();
tampered.push(0);
let err = decode_mask_edf(&tampered).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
let mut short = encode_mask_edf(4, 4, &[1u8; 16]);
short.truncate(short.len() - 8);
let err = decode_mask_edf(&short).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn mask_tiff_round_trips_bytes_and_shape() {
let data: Vec<u8> = vec![0, 1, 127, 200, 254, 255];
let bytes = encode_mask_tiff(2, 3, &data).expect("encode");
let (h, w, out) = decode_mask_tiff(&bytes).expect("decode");
assert_eq!((h, w), (2, 3));
assert_eq!(out, data);
}
#[test]
fn mask_tiff_rejects_a_multichannel_image() {
use tiff::encoder::{TiffEncoder, colortype::RGB8};
let mut cursor = std::io::Cursor::new(Vec::new());
TiffEncoder::new(&mut cursor)
.unwrap()
.write_image::<RGB8>(2, 2, &[0u8; 12])
.unwrap();
let rgb_tiff = cursor.into_inner();
let err = decode_mask_tiff(&rgb_tiff).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn mask_msk_round_trips_as_binary_with_odd_width() {
let (h, w) = (2u32, 33u32);
let mut data = vec![0u8; (h * w) as usize];
data[0] = 5; data[32] = 200; data[(w as usize) + 1] = 1; let bytes = encode_mask_msk(h, w, &data);
assert_eq!(bytes.len(), 1024 + 16);
let (dh, dw, out) = decode_mask_msk(&bytes).expect("decode");
assert_eq!((dh, dw), (h, w));
let mut expected = vec![0u8; (h * w) as usize];
expected[0] = 1;
expected[32] = 1;
expected[(w as usize) + 1] = 1;
assert_eq!(out, expected);
}
#[test]
fn mask_msk_rejects_bad_magic_and_short_header() {
assert_eq!(
decode_mask_msk(&[0u8; 16]).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
let mut buf = vec![0u8; 1024 + 4];
buf[0] = b'X'; assert_eq!(
decode_mask_msk(&buf).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
}
#[test]
fn mask_msk_matches_fabio_reference_bytes() {
let data: Vec<u8> = vec![0, 1, 0, 1, 1, 0, 0, 1, 0, 0];
let mut expected = vec![0u8; 1024 + 8];
expected[0] = b'M';
expected[4] = b'A';
expected[8] = b'S';
expected[12] = b'K';
expected[16] = 5; expected[20] = 2; expected[24] = 1;
expected[1024] = 0x1A;
expected[1028] = 0x04;
assert_eq!(encode_mask_msk(2, 5, &data), expected);
let (h, w, out) = decode_mask_msk(&expected).expect("decode");
assert_eq!((h, w), (2, 5));
assert_eq!(out, data);
}
#[test]
fn base64_encode_matches_known_vector() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn encode_svg_is_well_formed_with_size_and_png_payload() {
let rgba = [
11, 22, 33, 255, 44, 55, 66, 255, 77, 88, 99, 255, 1, 2, 3, 255,
];
let svg = encode_svg(&rgba, 2, 2).expect("svg");
assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>"));
assert!(svg.contains("<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\""));
assert!(svg.contains("width=\"2\""));
assert!(svg.contains("height=\"2\""));
assert!(svg.contains("<image xlink:href=\"data:image/png;base64,"));
assert!(svg.contains("x=\"0\""));
assert!(svg.contains("y=\"0\""));
assert!(svg.contains("id=\"image\" />"));
assert!(svg.trim_end().ends_with("</svg>"));
let marker = "base64,";
let start = svg.find(marker).expect("data uri") + marker.len();
let end = svg[start..].find('"').expect("end quote") + start;
let b64 = &svg[start..end];
let png_bytes = base64_decode_for_test(b64);
let decoder = png::Decoder::new(std::io::Cursor::new(&png_bytes));
let mut reader = decoder.read_info().expect("read info");
let mut buf = vec![0u8; reader.output_buffer_size().expect("buffer size")];
let info = reader.next_frame(&mut buf).expect("frame");
assert_eq!(info.width, 2);
assert_eq!(info.height, 2);
assert_eq!(info.color_type, png::ColorType::Rgb);
let expected_rgb = rgba_to_rgb(&rgba, 2, 2);
assert_eq!(&buf[..expected_rgb.len()], expected_rgb.as_slice());
}
fn base64_decode_for_test(s: &str) -> Vec<u8> {
fn val(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let mut out = Vec::new();
let mut acc = 0u32;
let mut bits = 0u32;
for &c in s.as_bytes() {
if c == b'=' {
break;
}
let Some(v) = val(c) else { continue };
acc = (acc << 6) | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
out
}
fn parse_tiff(bytes: &[u8]) -> ParsedTiff {
assert_eq!(&bytes[0..2], b"II", "byte order must be little-endian");
assert_eq!(u16::from_le_bytes([bytes[2], bytes[3]]), 42, "magic 42");
let ifd_off = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
let n = u16::from_le_bytes([bytes[ifd_off], bytes[ifd_off + 1]]) as usize;
let mut tags = std::collections::HashMap::new();
for i in 0..n {
let base = ifd_off + 2 + i * 12;
let tag = u16::from_le_bytes([bytes[base], bytes[base + 1]]);
let typ = u16::from_le_bytes([bytes[base + 2], bytes[base + 3]]);
let count = u32::from_le_bytes([
bytes[base + 4],
bytes[base + 5],
bytes[base + 6],
bytes[base + 7],
]);
let val = [
bytes[base + 8],
bytes[base + 9],
bytes[base + 10],
bytes[base + 11],
];
tags.insert(tag, (typ, count, val));
}
let next_off = ifd_off + 2 + n * 12;
assert_eq!(
u32::from_le_bytes([
bytes[next_off],
bytes[next_off + 1],
bytes[next_off + 2],
bytes[next_off + 3]
]),
0,
"single-image TIFF: next IFD offset is 0"
);
let width = le_u32(&tags[&256].2);
let height = le_u32(&tags[&257].2);
let strip_off = le_u32(&tags[&273].2) as usize;
let strip_len = le_u32(&tags[&279].2) as usize;
let pixels = bytes[strip_off..strip_off + strip_len].to_vec();
(width, height, tags, pixels)
}
fn le_u32(v: &[u8; 4]) -> u32 {
u32::from_le_bytes(*v)
}
fn le_short(v: &[u8; 4]) -> u16 {
u16::from_le_bytes([v[0], v[1]])
}
fn read_rational(bytes: &[u8], off: u32) -> (u32, u32) {
let o = off as usize;
let num = u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
let den = u32::from_le_bytes([bytes[o + 4], bytes[o + 5], bytes[o + 6], bytes[o + 7]]);
(num, den)
}
#[test]
fn encode_tiff_header_tags_and_pixels_round_trip() {
let rgba = [
10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255,
];
let tiff = encode_tiff(&rgba, 2, 2, 96);
let (w, h, tags, pixels) = parse_tiff(&tiff);
assert_eq!((w, h), (2, 2));
assert_eq!(le_short(&tags[&259].2), 1, "Compression = none");
assert_eq!(le_short(&tags[&262].2), 2, "Photometric = RGB");
assert_eq!(le_short(&tags[&277].2), 3, "SamplesPerPixel = 3");
assert_eq!(le_u32(&tags[&278].2), 2, "RowsPerStrip = height");
assert_eq!(le_u32(&tags[&279].2), 2 * 2 * 3, "StripByteCounts = w*h*3");
let (typ, count, bits_val) = tags[&258];
assert_eq!(typ, 3);
assert_eq!(count, 3);
let bits_off = le_u32(&bits_val) as usize;
assert_eq!(
&tiff[bits_off..bits_off + 6],
&[8, 0, 8, 0, 8, 0],
"BitsPerSample = 8,8,8"
);
let expected_rgb = rgba_to_rgb(&rgba, 2, 2);
assert_eq!(pixels, expected_rgb);
}
#[test]
fn encode_tiff_resolution_tags_reflect_dpi() {
let rgba = [1, 2, 3, 255];
let tiff = encode_tiff(&rgba, 1, 1, 300);
let (_, _, tags, _) = parse_tiff(&tiff);
assert_eq!(le_short(&tags[&296].2), 2, "ResolutionUnit = inch");
let xres = read_rational(&tiff, le_u32(&tags[&282].2));
let yres = read_rational(&tiff, le_u32(&tags[&283].2));
assert_eq!(xres, (300, 1), "XResolution = 300 dpi");
assert_eq!(yres, (300, 1), "YResolution = 300 dpi");
}
#[test]
fn encode_tiff_clamps_zero_dpi_to_one() {
let rgba = [1, 2, 3, 255];
let tiff = encode_tiff(&rgba, 1, 1, 0);
let (_, _, tags, _) = parse_tiff(&tiff);
assert_eq!(read_rational(&tiff, le_u32(&tags[&282].2)), (1, 1));
}
}