Files
maCopy/src-tauri/src/image_util.rs
T
ilia 656045c4b4
CI / skip-ci-check (pull_request) Successful in 9s
CI / secret-scan (pull_request) Successful in 8s
CI / node-ci (pull_request) Successful in 26s
Capture oversized screenshots instead of dropping them.
Retina clipboard images over the RGBA size cap were silently skipped; downscale and store a thumbnail so they still appear in history, with regression tests.
2026-07-19 11:14:19 -04:00

206 lines
7.2 KiB
Rust

//! Image helpers: PNG encode/decode and small list thumbnails.
use base64::Engine;
use std::borrow::Cow;
pub const THUMB_MAX_EDGE: u32 = 96;
pub fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, 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(buf)
}
pub fn decode_png_rgba(png_data: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
let decoder = png::Decoder::new(std::io::Cursor::new(png_data));
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let mut buf = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
buf.truncate(info.buffer_size());
// Expand to RGBA if needed
let rgba = match info.color_type {
png::ColorType::Rgba => buf,
png::ColorType::Rgb => {
let mut out = Vec::with_capacity((buf.len() / 3) * 4);
for chunk in buf.chunks_exact(3) {
out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
out
}
png::ColorType::Grayscale => {
let mut out = Vec::with_capacity(buf.len() * 4);
for &g in &buf {
out.extend_from_slice(&[g, g, g, 255]);
}
out
}
png::ColorType::GrayscaleAlpha => {
let mut out = Vec::with_capacity((buf.len() / 2) * 4);
for chunk in buf.chunks_exact(2) {
out.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
}
out
}
other => return Err(format!("unsupported PNG color type: {:?}", other)),
};
Ok((info.width, info.height, rgba))
}
/// Nearest-neighbor downscale so the longest edge is ≤ `max_edge`.
pub fn downscale_rgba(width: u32, height: u32, rgba: &[u8], max_edge: u32) -> (u32, u32, Vec<u8>) {
if width == 0 || height == 0 || rgba.len() < (width as usize * height as usize * 4) {
return (width.max(1), height.max(1), vec![0, 0, 0, 0]);
}
let longest = width.max(height);
if longest <= max_edge {
return (width, height, rgba.to_vec());
}
let scale = max_edge as f32 / longest as f32;
let tw = ((width as f32) * scale).round().max(1.0) as u32;
let th = ((height as f32) * scale).round().max(1.0) as u32;
let mut out = vec![0u8; (tw as usize) * (th as usize) * 4];
for y in 0..th {
let sy = ((y as f32 / th as f32) * height as f32) as u32;
for x in 0..tw {
let sx = ((x as f32 / tw as f32) * width as f32) as u32;
let si = ((sy * width + sx) as usize) * 4;
let di = ((y * tw + x) as usize) * 4;
out[di..di + 4].copy_from_slice(&rgba[si..si + 4]);
}
}
(tw, th, out)
}
pub fn rgba_to_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<String, String> {
let png = encode_rgba_to_png(width, height, rgba).map_err(|e| e.to_string())?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
Ok(format!("data:image/png;base64,{}", b64))
}
pub fn make_thumbnail_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<String, String> {
let (tw, th, thumb) = downscale_rgba(width, height, rgba, THUMB_MAX_EDGE);
rgba_to_data_uri(tw, th, &thumb)
}
/// Shrink RGBA so `width * height * 4 <= max_bytes`, preserving aspect ratio.
/// Returns the original buffer unchanged when already under the limit.
pub fn fit_rgba_under_bytes(
width: u32,
height: u32,
rgba: &[u8],
max_bytes: usize,
) -> (u32, u32, Vec<u8>) {
let need = (width as usize)
.saturating_mul(height as usize)
.saturating_mul(4);
if need == 0 || need <= max_bytes {
return (width.max(1), height.max(1), rgba.to_vec());
}
let max_pixels = (max_bytes / 4).max(1) as f64;
let pixels = (width as f64) * (height as f64);
let scale = (max_pixels / pixels).sqrt();
let max_edge = ((width.max(height) as f64) * scale).floor().max(1.0) as u32;
downscale_rgba(width, height, rgba, max_edge)
}
pub fn data_uri_to_rgba(data_uri: &str) -> Result<(u32, u32, Vec<u8>), String> {
let b64 = data_uri
.split_once(',')
.map(|(_, b)| b)
.ok_or_else(|| "invalid data URI".to_string())?;
let png = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| e.to_string())?;
decode_png_rgba(&png)
}
pub fn to_arboard_image(width: u32, height: u32, rgba: Vec<u8>) -> arboard::ImageData<'static> {
arboard::ImageData {
width: width as usize,
height: height as usize,
bytes: Cow::Owned(rgba),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumbnail_shrinks_large_image() {
let w = 400u32;
let h = 200u32;
let rgba = vec![128u8; (w * h * 4) as usize];
let (tw, th, out) = downscale_rgba(w, h, &rgba, 96);
assert!(tw <= 96 && th <= 96);
assert_eq!(out.len(), (tw * th * 4) as usize);
assert!(tw >= th); // landscape stays wider
}
#[test]
fn roundtrip_png_small() {
let rgba = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255];
let png = encode_rgba_to_png(2, 2, &rgba).unwrap();
let (w, h, out) = decode_png_rgba(&png).unwrap();
assert_eq!((w, h), (2, 2));
assert_eq!(out.len(), 16);
}
#[test]
fn make_thumbnail_data_uri_ok() {
let rgba = vec![10u8; 50 * 50 * 4];
let uri = make_thumbnail_data_uri(50, 50, &rgba).unwrap();
assert!(uri.starts_with("data:image/png;base64,"));
}
#[test]
fn fit_rgba_leaves_small_images_alone() {
let rgba = vec![1u8; 10 * 10 * 4];
let (w, h, out) = fit_rgba_under_bytes(10, 10, &rgba, 16 * 1024 * 1024);
assert_eq!((w, h), (10, 10));
assert_eq!(out.len(), rgba.len());
}
#[test]
fn fit_rgba_shrinks_oversized_buffer() {
let w = 2500u32;
let h = 1700u32;
let rgba = vec![40u8; (w * h * 4) as usize];
let max = 16 * 1024 * 1024;
assert!(rgba.len() > max);
let (tw, th, out) = fit_rgba_under_bytes(w, h, &rgba, max);
assert!(out.len() <= max);
assert!(tw < w || th < h);
assert_eq!(out.len(), (tw * th * 4) as usize);
}
#[test]
fn fit_rgba_uses_production_max_image_bytes_cap() {
// Lock the constant the clipboard poller uses — if someone raises the
// skip threshold again without downscale, this fixture still exceeds it.
let max = crate::clipboard::MAX_IMAGE_BYTES;
let w = 3024u32;
let h = 1964u32;
let rgba = vec![0u8; (w * h * 4) as usize];
assert!(rgba.len() > max);
let (tw, th, out) = fit_rgba_under_bytes(w, h, &rgba, max);
assert!(out.len() <= max);
assert!(tw > 0 && th > 0);
}
#[test]
fn downscale_handles_zero_dimensions() {
let (w, h, out) = downscale_rgba(0, 0, &[], 96);
assert!(w >= 1 && h >= 1);
assert_eq!(out.len(), 4); // single transparent pixel path
}
}