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.
206 lines
7.2 KiB
Rust
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
|
|
}
|
|
}
|