Capture oversized screenshots instead of dropping them #6
@ -16,14 +16,14 @@ maCopy has an automated suite. There is no separate “live” Storybook/UI docs
|
|||||||
| `npm run lint` | TypeScript `tsc --noEmit` |
|
| `npm run lint` | TypeScript `tsc --noEmit` |
|
||||||
| `npm run check` | Lint + all tests (CI-style gate) |
|
| `npm run check` | Lint + all tests (CI-style gate) |
|
||||||
|
|
||||||
Current counts (approx.): **~60 frontend** + **~52 Rust** unit tests.
|
Current counts (approx.): **~60 frontend** + **~65 Rust** unit tests.
|
||||||
|
|
||||||
## What is covered
|
## What is covered
|
||||||
|
|
||||||
### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`)
|
### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`)
|
||||||
|
|
||||||
- App: load entries, paste invoke, delete via keyboard
|
- App: load entries, paste invoke, delete via keyboard
|
||||||
- ClipboardList: selection, multi-select modifiers, image stubs/thumbs, Alt=plain paste
|
- ClipboardList: selection, multi-select modifiers, image stubs/thumbs (including truncated thumb previews), Alt=plain paste
|
||||||
- ContextMenu: Paste / Paste plain / transforms / Pin / Delete
|
- ContextMenu: Paste / Paste plain / transforms / Pin / Delete
|
||||||
- SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable**
|
- SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable**
|
||||||
- SearchBar basics
|
- SearchBar basics
|
||||||
@ -34,18 +34,23 @@ Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard,
|
|||||||
### Backend (`src-tauri/src/**` `#[cfg(test)]`)
|
### Backend (`src-tauri/src/**` `#[cfg(test)]`)
|
||||||
|
|
||||||
- SQLite CRUD, FTS5, trim + pin preservation, settings
|
- SQLite CRUD, FTS5, trim + pin preservation, settings
|
||||||
- List previews: truncate text, omit image blobs, return thumbnails
|
- List previews: truncate text, omit image blobs, return thumbnails (including stub `[image WxH]` rows)
|
||||||
|
- Search excludes image rows (text-only FTS)
|
||||||
- Sensitive redaction (card / password / banking) while `get_entry` stays full
|
- Sensitive redaction (card / password / banking) while `get_entry` stays full
|
||||||
- Hotkey parser (`cmd+\`` etc.)
|
- Hotkey parser (`cmd+\`` etc.)
|
||||||
- Image thumbnail / PNG helpers
|
- Image thumbnail / PNG helpers + `fit_rgba_under_bytes` against production `MAX_IMAGE_BYTES`
|
||||||
|
- **Oversized / Retina screenshot prepare** — never skip; downscale + thumb + DB list preview
|
||||||
- Clipboard content hashing fingerprints
|
- Clipboard content hashing fingerprints
|
||||||
|
|
||||||
## What is *not* covered (yet)
|
## What is *not* covered (yet)
|
||||||
|
|
||||||
- End-to-end UI on a real Mac (no Playwright against the Tauri window)
|
- End-to-end UI on a real Mac (no Playwright against the Tauri window)
|
||||||
|
- Live pasteboard polling (requires macOS Accessibility / real `NSPasteboard`)
|
||||||
- Accessibility permission / AppleScript paste in CI
|
- Accessibility permission / AppleScript paste in CI
|
||||||
- Notarized release smoke tests
|
- Notarized release smoke tests
|
||||||
- Performance benchmarks in CI
|
- Performance benchmarks in CI
|
||||||
|
- Prefer-image-when-text-also-present (poller text-wins path)
|
||||||
|
- Exclude-apps / pause-monitoring integration
|
||||||
|
|
||||||
## Recommended workflow
|
## Recommended workflow
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::image_util::{encode_rgba_to_png, make_thumbnail_data_uri};
|
use crate::image_util::{encode_rgba_to_png, fit_rgba_under_bytes, make_thumbnail_data_uri};
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@ -8,10 +8,23 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
/// Skip encoding RGBA buffers larger than this (keeps history DB usable).
|
/// Max RGBA bytes kept for encode/storage (keeps history DB usable).
|
||||||
const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024;
|
/// Larger pasteboard images are downscaled — never silently dropped.
|
||||||
|
pub(crate) const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||||
/// Cap stored full PNG size; list uses a separate tiny thumbnail.
|
/// Cap stored full PNG size; list uses a separate tiny thumbnail.
|
||||||
const MAX_STORED_PNG_BYTES: usize = 1_500_000;
|
pub(crate) const MAX_STORED_PNG_BYTES: usize = 1_500_000;
|
||||||
|
|
||||||
|
/// Result of turning a pasteboard RGBA buffer into DB-ready fields.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PreparedImage {
|
||||||
|
pub content: String,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub downscaled: bool,
|
||||||
|
/// True when `content` is a stub and only the thumbnail is visual.
|
||||||
|
pub thumbnail_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Hash arbitrary bytes for deduplication.
|
/// Hash arbitrary bytes for deduplication.
|
||||||
pub fn hash_content(data: &[u8]) -> String {
|
pub fn hash_content(data: &[u8]) -> String {
|
||||||
@ -21,7 +34,7 @@ pub fn hash_content(data: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cheap image fingerprint: dimensions + length + edge samples (not full-buffer SHA).
|
/// Cheap image fingerprint: dimensions + length + edge samples (not full-buffer SHA).
|
||||||
fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String {
|
pub(crate) fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update((width as u64).to_le_bytes());
|
hasher.update((width as u64).to_le_bytes());
|
||||||
hasher.update((height as u64).to_le_bytes());
|
hasher.update((height as u64).to_le_bytes());
|
||||||
@ -37,6 +50,51 @@ fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String {
|
|||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Downscale if needed, build thumbnail + PNG (or stub). Never returns "skip".
|
||||||
|
/// This is the regression lock for Retina screenshots that used to vanish.
|
||||||
|
pub(crate) fn prepare_clipboard_image(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
rgba: &[u8],
|
||||||
|
max_rgba_bytes: usize,
|
||||||
|
max_png_bytes: usize,
|
||||||
|
) -> Result<PreparedImage, String> {
|
||||||
|
let orig_w = width;
|
||||||
|
let orig_h = height;
|
||||||
|
let (w, h, fitted) = fit_rgba_under_bytes(width, height, rgba, max_rgba_bytes);
|
||||||
|
let downscaled = w < orig_w || h < orig_h;
|
||||||
|
let thumb = make_thumbnail_data_uri(w, h, &fitted).ok();
|
||||||
|
|
||||||
|
match encode_rgba_to_png(w, h, &fitted) {
|
||||||
|
Ok(png_data) if png_data.len() <= max_png_bytes => {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
|
||||||
|
Ok(PreparedImage {
|
||||||
|
content: format!("data:image/png;base64,{}", b64),
|
||||||
|
thumbnail: thumb,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
downscaled,
|
||||||
|
thumbnail_only: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
let stub = format!("[image {}x{}]", w, h);
|
||||||
|
if thumb.is_none() {
|
||||||
|
return Err("PNG too large and thumbnail failed".into());
|
||||||
|
}
|
||||||
|
Ok(PreparedImage {
|
||||||
|
content: stub,
|
||||||
|
thumbnail: thumb,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
downscaled,
|
||||||
|
thumbnail_only: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("PNG encode failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared state so paste_and_refocus can update the hash the polling
|
/// Shared state so paste_and_refocus can update the hash the polling
|
||||||
/// thread compares against, preventing re-insertion of pasted content.
|
/// thread compares against, preventing re-insertion of pasted content.
|
||||||
pub type LastHash = Arc<Mutex<String>>;
|
pub type LastHash = Arc<Mutex<String>>;
|
||||||
@ -115,56 +173,52 @@ pub fn start_polling(app: AppHandle, db: Arc<Database>, paused: Arc<AtomicBool>)
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Ok(img) = clipboard.get_image() {
|
if let Ok(img) = clipboard.get_image() {
|
||||||
if img.bytes.len() > MAX_IMAGE_BYTES {
|
// Fingerprint the original pasteboard image for dedup, then
|
||||||
log::warn!(
|
// downscale if needed. Previously we skipped anything over
|
||||||
"Skipping oversized clipboard image ({} bytes)",
|
// MAX_IMAGE_BYTES — Retina screenshots never appeared.
|
||||||
img.bytes.len()
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let h = fingerprint_image(img.width, img.height, &img.bytes);
|
let h = fingerprint_image(img.width, img.height, &img.bytes);
|
||||||
if h == current_hash {
|
if h == current_hash {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
*hash_for_thread.lock().unwrap() = h.clone();
|
*hash_for_thread.lock().unwrap() = h.clone();
|
||||||
|
|
||||||
let w = img.width as u32;
|
match prepare_clipboard_image(
|
||||||
let hgt = img.height as u32;
|
img.width as u32,
|
||||||
let thumb = make_thumbnail_data_uri(w, hgt, &img.bytes).ok();
|
img.height as u32,
|
||||||
|
&img.bytes,
|
||||||
match encode_rgba_to_png(w, hgt, &img.bytes) {
|
MAX_IMAGE_BYTES,
|
||||||
Ok(png_data) if png_data.len() <= MAX_STORED_PNG_BYTES => {
|
MAX_STORED_PNG_BYTES,
|
||||||
let b64 =
|
) {
|
||||||
base64::engine::general_purpose::STANDARD.encode(&png_data);
|
Ok(prepared) => {
|
||||||
let data_uri = format!("data:image/png;base64,{}", b64);
|
if prepared.downscaled {
|
||||||
if let Err(e) =
|
log::info!(
|
||||||
db.insert_entry(&data_uri, "image", &h, thumb.as_deref())
|
"Downscaled clipboard image {}x{} → {}x{} for storage",
|
||||||
{
|
img.width,
|
||||||
|
img.height,
|
||||||
|
prepared.width,
|
||||||
|
prepared.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if prepared.thumbnail_only {
|
||||||
|
log::warn!(
|
||||||
|
"Stored thumbnail only for large PNG ({}x{})",
|
||||||
|
prepared.width,
|
||||||
|
prepared.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Err(e) = db.insert_entry(
|
||||||
|
&prepared.content,
|
||||||
|
"image",
|
||||||
|
&h,
|
||||||
|
prepared.thumbnail.as_deref(),
|
||||||
|
) {
|
||||||
log::error!("DB insert error (image): {}", e);
|
log::error!("DB insert error (image): {}", e);
|
||||||
} else {
|
} else {
|
||||||
let _ = app.emit("clipboard-changed", ());
|
let _ = app.emit("clipboard-changed", ());
|
||||||
}
|
}
|
||||||
trim_if_needed(&db);
|
trim_if_needed(&db);
|
||||||
}
|
}
|
||||||
Ok(png_data) => {
|
Err(e) => log::error!("Image prepare failed: {}", e),
|
||||||
// Still store a thumbnail-only entry so the list can show something.
|
|
||||||
if let Some(ref t) = thumb {
|
|
||||||
let stub = format!("[image {}x{}]", w, hgt);
|
|
||||||
if db
|
|
||||||
.insert_entry(&stub, "image", &h, Some(t))
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
let _ = app.emit("clipboard-changed", ());
|
|
||||||
trim_if_needed(&db);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log::warn!(
|
|
||||||
"Stored thumbnail only for large PNG ({} bytes encoded)",
|
|
||||||
png_data.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => log::error!("PNG encode failed: {}", e),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -184,6 +238,8 @@ fn trim_if_needed(db: &Database) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::image_util::data_uri_to_rgba;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hash_content_deterministic() {
|
fn hash_content_deterministic() {
|
||||||
@ -241,4 +297,119 @@ mod tests {
|
|||||||
let result = encode_rgba_to_png(0, 0, &[]);
|
let result = encode_rgba_to_png(0, 0, &[]);
|
||||||
assert!(result.is_ok() || result.is_err());
|
assert!(result.is_ok() || result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: Retina-class screenshots used to be skipped entirely when
|
||||||
|
/// RGBA exceeded MAX_IMAGE_BYTES. They must still produce a listable entry.
|
||||||
|
#[test]
|
||||||
|
fn prepare_retina_screenshot_is_never_skipped() {
|
||||||
|
// ~14" MBP Retina full-screen-ish: far over the 16MB RGBA cap.
|
||||||
|
let w = 3024u32;
|
||||||
|
let h = 1964u32;
|
||||||
|
let rgba_len = (w as usize) * (h as usize) * 4;
|
||||||
|
assert!(
|
||||||
|
rgba_len > MAX_IMAGE_BYTES,
|
||||||
|
"fixture must exceed MAX_IMAGE_BYTES to exercise the bug"
|
||||||
|
);
|
||||||
|
let rgba = vec![60u8; rgba_len];
|
||||||
|
|
||||||
|
let prepared =
|
||||||
|
prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES)
|
||||||
|
.expect("oversized screenshot must prepare, not skip");
|
||||||
|
|
||||||
|
assert!(prepared.downscaled);
|
||||||
|
assert!(
|
||||||
|
(prepared.width as usize) * (prepared.height as usize) * 4 <= MAX_IMAGE_BYTES
|
||||||
|
);
|
||||||
|
assert!(prepared.thumbnail.is_some());
|
||||||
|
let thumb = prepared.thumbnail.as_ref().unwrap();
|
||||||
|
assert!(
|
||||||
|
thumb.starts_with("data:image/png;base64,"),
|
||||||
|
"list preview needs a real data URI thumb"
|
||||||
|
);
|
||||||
|
assert!(!prepared.content.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_small_image_keeps_full_png() {
|
||||||
|
let w = 32u32;
|
||||||
|
let h = 24u32;
|
||||||
|
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||||
|
for px in rgba.chunks_exact_mut(4) {
|
||||||
|
px.copy_from_slice(&[10, 200, 80, 255]);
|
||||||
|
}
|
||||||
|
let prepared =
|
||||||
|
prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES).unwrap();
|
||||||
|
assert!(!prepared.downscaled);
|
||||||
|
assert!(!prepared.thumbnail_only);
|
||||||
|
assert!(prepared.content.starts_with("data:image/png;base64,"));
|
||||||
|
let (rw, rh, _) = data_uri_to_rgba(&prepared.content).unwrap();
|
||||||
|
assert_eq!((rw, rh), (w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_thumbnail_only_when_png_cap_tiny() {
|
||||||
|
let w = 200u32;
|
||||||
|
let h = 200u32;
|
||||||
|
// High-entropy noise → PNG won't shrink under a tiny cap.
|
||||||
|
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
||||||
|
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
|
||||||
|
let v = (i % 251) as u8;
|
||||||
|
px.copy_from_slice(&[v, v.wrapping_mul(3), v.wrapping_mul(7), 255]);
|
||||||
|
}
|
||||||
|
let prepared = prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, 200).unwrap();
|
||||||
|
assert!(prepared.thumbnail_only);
|
||||||
|
assert!(prepared.content.starts_with("[image "));
|
||||||
|
assert!(prepared
|
||||||
|
.thumbnail
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("data:image/png;base64,"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_then_insert_shows_thumb_in_list() {
|
||||||
|
let w = 2500u32;
|
||||||
|
let h = 1700u32;
|
||||||
|
let rgba = vec![90u8; (w * h * 4) as usize];
|
||||||
|
let prepared =
|
||||||
|
prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES).unwrap();
|
||||||
|
|
||||||
|
let db = Database::in_memory().unwrap();
|
||||||
|
let hash = fingerprint_image(w as usize, h as usize, &rgba);
|
||||||
|
let id = db
|
||||||
|
.insert_entry(
|
||||||
|
&prepared.content,
|
||||||
|
"image",
|
||||||
|
&hash,
|
||||||
|
prepared.thumbnail.as_deref(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let list = db.get_entries(10).unwrap();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0].id, id);
|
||||||
|
assert_eq!(list[0].content_type, "image");
|
||||||
|
assert!(
|
||||||
|
list[0].content.starts_with("data:image/png;base64,"),
|
||||||
|
"UI renders <img> only when list content is a data URI; got {:?}",
|
||||||
|
&list[0].content[..list[0].content.len().min(40)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_preserves_landscape_aspect_roughly() {
|
||||||
|
let w = 4800u32;
|
||||||
|
let h = 1600u32; // 29.3 MB RGBA → must downscale
|
||||||
|
let rgba = vec![1u8; (w * h * 4) as usize];
|
||||||
|
assert!((w as usize) * (h as usize) * 4 > MAX_IMAGE_BYTES);
|
||||||
|
let prepared =
|
||||||
|
prepare_clipboard_image(w, h, &rgba, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES).unwrap();
|
||||||
|
assert!(prepared.downscaled);
|
||||||
|
let orig_aspect = w as f64 / h as f64;
|
||||||
|
let new_aspect = prepared.width as f64 / prepared.height as f64;
|
||||||
|
assert!(
|
||||||
|
(orig_aspect - new_aspect).abs() < 0.05,
|
||||||
|
"aspect {orig_aspect} vs {new_aspect}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -754,6 +754,38 @@ mod tests {
|
|||||||
assert_eq!(full.content, blob);
|
assert_eq!(full.content, blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_shows_thumb_for_stub_image_entry() {
|
||||||
|
// Oversized-PNG path stores "[image WxH]" + thumbnail — list must still
|
||||||
|
// expose a data URI so the UI can render a preview.
|
||||||
|
let db = test_db();
|
||||||
|
let thumb = "data:image/png;base64,stubthumb";
|
||||||
|
db.insert_entry("[image 2800x1600]", "image", "hstub", Some(thumb))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let entries = db.get_entries(10).unwrap();
|
||||||
|
assert_eq!(entries[0].content, thumb);
|
||||||
|
assert!(entries[0].truncated);
|
||||||
|
assert_eq!(entries[0].content_type, "image");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_entries_excludes_images() {
|
||||||
|
let db = test_db();
|
||||||
|
db.insert_entry("findme text", "text", "ht1", None).unwrap();
|
||||||
|
db.insert_entry(
|
||||||
|
"data:image/png;base64,findme",
|
||||||
|
"image",
|
||||||
|
"hi1",
|
||||||
|
Some("data:image/png;base64,thumb"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let hits = db.search_entries("findme", 50).unwrap();
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].content_type, "text");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_entries_truncate_long_text() {
|
fn list_entries_truncate_long_text() {
|
||||||
let db = test_db();
|
let db = test_db();
|
||||||
|
|||||||
@ -90,6 +90,27 @@ pub fn make_thumbnail_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<S
|
|||||||
rgba_to_data_uri(tw, th, &thumb)
|
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> {
|
pub fn data_uri_to_rgba(data_uri: &str) -> Result<(u32, u32, Vec<u8>), String> {
|
||||||
let b64 = data_uri
|
let b64 = data_uri
|
||||||
.split_once(',')
|
.split_once(',')
|
||||||
@ -139,4 +160,46 @@ mod tests {
|
|||||||
let uri = make_thumbnail_data_uri(50, 50, &rgba).unwrap();
|
let uri = make_thumbnail_data_uri(50, 50, &rgba).unwrap();
|
||||||
assert!(uri.starts_with("data:image/png;base64,"));
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,6 +105,22 @@ describe("ClipboardList", () => {
|
|||||||
expect(img).toHaveAttribute("src", "data:image/png;base64,abc");
|
expect(img).toHaveAttribute("src", "data:image/png;base64,abc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders thumbnail data URI for truncated oversized-image list rows", () => {
|
||||||
|
// Backend list preview swaps full PNG for thumbnail; truncated stays true.
|
||||||
|
const entries = [
|
||||||
|
makeEntry({
|
||||||
|
content: "data:image/png;base64,thumbpreview",
|
||||||
|
content_type: "image",
|
||||||
|
truncated: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
render(<ClipboardList {...defaultProps} entries={entries} showImages={true} />);
|
||||||
|
expect(screen.getByAltText("Clipboard image")).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"data:image/png;base64,thumbpreview"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders image stub when blob was omitted from list payload", () => {
|
it("renders image stub when blob was omitted from list payload", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ content: "", content_type: "image", truncated: true }),
|
makeEntry({ content: "", content_type: "image", truncated: true }),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user