From 055b95a1818d8466e968b9a7e1d8ec0d1bb4a8a8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 19:33:51 +0200 Subject: [PATCH] fix(pf-clipboard): winfmt layout + wayland map test for the JPEG/GIF pass-through Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/pf-clipboard/src/host.rs | 5 +- crates/pf-clipboard/src/host/winfmt.rs | 166 ++++++++++++------------- 2 files changed, 87 insertions(+), 84 deletions(-) diff --git a/crates/pf-clipboard/src/host.rs b/crates/pf-clipboard/src/host.rs index 8dbf8bbf..fab86bba 100644 --- a/crates/pf-clipboard/src/host.rs +++ b/crates/pf-clipboard/src/host.rs @@ -367,10 +367,13 @@ mod tests { assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML)); assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF)); assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG)); + // Original image formats now map to their own wire kinds (verbatim pass-through). + assert_eq!(wayland_to_wire("image/jpeg"), Some(WIRE_JPEG)); + assert_eq!(wayland_to_wire("image/gif"), Some(WIRE_GIF)); // Internal targets and unsupported formats are dropped. assert_eq!(wayland_to_wire("TARGETS"), None); assert_eq!(wayland_to_wire("TIMESTAMP"), None); - assert_eq!(wayland_to_wire("image/jpeg"), None); + assert_eq!(wayland_to_wire("image/webp"), None); } #[test] diff --git a/crates/pf-clipboard/src/host/winfmt.rs b/crates/pf-clipboard/src/host/winfmt.rs index 0eb6d8dd..81f2de8c 100644 --- a/crates/pf-clipboard/src/host/winfmt.rs +++ b/crates/pf-clipboard/src/host/winfmt.rs @@ -158,6 +158,89 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] { } } +// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------ +// +// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes +// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when +// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER: +// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows. + +/// Image wire bytes (PNG / JPEG / GIF — any format the `image` crate sniffs) → `CF_DIB` HGLOBAL +/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame. +/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste). +pub fn image_to_dib(bytes: &[u8]) -> Option> { + let img = image::load_from_memory(bytes).ok()?; + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width() as usize, rgba.height() as usize); + if w == 0 || h == 0 || w > 32767 || h > 32767 { + return None; + } + let mut out = Vec::with_capacity(40 + w * h * 4); + // BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows. + out.extend_from_slice(&40u32.to_le_bytes()); // biSize + out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth + out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up) + out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes + out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount + out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB + out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage + out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant + // Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned). + for row in rgba.rows().rev() { + for px in row { + let [r, g, b, a] = px.0; + out.extend_from_slice(&[b, g, r, a]); + } + } + Some(out) +} + +/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects +/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on +/// any malformed input — the caller declines the fetch. +pub fn dib_to_png(dib: &[u8]) -> Option> { + if dib.len() < 40 { + return None; + } + let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize; + if hdr_size < 40 || hdr_size > dib.len() { + return None; + } + let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize; + let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?); + let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize; + // Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels. + let palette = if bit_count <= 8 { + (if clr_used != 0 { + clr_used + } else { + 1 << bit_count + }) * 4 + } else if compression == 3 { + // BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER — + // V4/V5 headers already contain the masks within hdr_size). + if hdr_size == 40 { + 12 + } else { + 0 + } + } else { + 0 + }; + let pixel_offset = 14 + hdr_size + palette; + let mut bmp = Vec::with_capacity(14 + dib.len()); + bmp.extend_from_slice(b"BM"); + bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes()); + bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved + bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes()); + bmp.extend_from_slice(dib); + let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .ok()?; + Some(png) +} + #[cfg(test)] mod tests { use super::*; @@ -282,86 +365,3 @@ mod tests { assert!(dib_to_png(&[0xFFu8; 200]).is_none()); } } - -// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------ -// -// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes -// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when -// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER: -// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows. - -/// Image wire bytes (PNG / JPEG / GIF — any format the `image` crate sniffs) → `CF_DIB` HGLOBAL -/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame. -/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste). -pub fn image_to_dib(bytes: &[u8]) -> Option> { - let img = image::load_from_memory(bytes).ok()?; - let rgba = img.to_rgba8(); - let (w, h) = (rgba.width() as usize, rgba.height() as usize); - if w == 0 || h == 0 || w > 32767 || h > 32767 { - return None; - } - let mut out = Vec::with_capacity(40 + w * h * 4); - // BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows. - out.extend_from_slice(&40u32.to_le_bytes()); // biSize - out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth - out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up) - out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes - out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount - out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB - out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage - out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant - // Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned). - for row in rgba.rows().rev() { - for px in row { - let [r, g, b, a] = px.0; - out.extend_from_slice(&[b, g, r, a]); - } - } - Some(out) -} - -/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects -/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on -/// any malformed input — the caller declines the fetch. -pub fn dib_to_png(dib: &[u8]) -> Option> { - if dib.len() < 40 { - return None; - } - let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize; - if hdr_size < 40 || hdr_size > dib.len() { - return None; - } - let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize; - let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?); - let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize; - // Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels. - let palette = if bit_count <= 8 { - (if clr_used != 0 { - clr_used - } else { - 1 << bit_count - }) * 4 - } else if compression == 3 { - // BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER — - // V4/V5 headers already contain the masks within hdr_size). - if hdr_size == 40 { - 12 - } else { - 0 - } - } else { - 0 - }; - let pixel_offset = 14 + hdr_size + palette; - let mut bmp = Vec::with_capacity(14 + dib.len()); - bmp.extend_from_slice(b"BM"); - bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes()); - bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved - bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes()); - bmp.extend_from_slice(dib); - let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?; - let mut png = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .ok()?; - Some(png) -}