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]))
}
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
}
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,
}
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),
_ => None,
}
}
pub fn from_path(path: &Path) -> Option<Self> {
path.extension()
.and_then(|e| e.to_str())
.and_then(Self::from_extension)
}
}
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 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 siplot::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,
)
}
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)?;
}
}
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]); }
#[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));
}
#[test]
fn save_format_rejects_matplotlib_only_and_unknown_extensions() {
assert_eq!(SaveFormat::from_extension("pdf"), None);
assert_eq!(SaveFormat::from_extension("ps"), None);
assert_eq!(SaveFormat::from_extension("eps"), None);
assert_eq!(SaveFormat::from_extension("jpeg"), None);
assert_eq!(SaveFormat::from_extension("jpg"), 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 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 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));
}
}