diff --git a/crates/pf-clipboard/Cargo.toml b/crates/pf-clipboard/Cargo.toml index 9b2970e4..8eaade85 100644 --- a/crates/pf-clipboard/Cargo.toml +++ b/crates/pf-clipboard/Cargo.toml @@ -20,6 +20,9 @@ anyhow = "1" tracing = "0.1" quinn = "0.11" tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } +# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format. +# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host. +image = { version = "0.25", default-features = false, features = ["png", "bmp"] } [target.'cfg(target_os = "linux")'.dependencies] # Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg diff --git a/crates/pf-clipboard/src/host/windows.rs b/crates/pf-clipboard/src/host/windows.rs index 503b53fc..9efc2901 100644 --- a/crates/pf-clipboard/src/host/windows.rs +++ b/crates/pf-clipboard/src/host/windows.rs @@ -39,7 +39,7 @@ use ::windows::Win32::System::LibraryLoader::GetModuleHandleW; use ::windows::Win32::System::Memory::{ GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT, }; -use ::windows::Win32::System::Ole::CF_UNICODETEXT; +use ::windows::Win32::System::Ole::{CF_DIB, CF_UNICODETEXT}; use ::windows::Win32::UI::WindowsAndMessaging::{ CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW, GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW, @@ -131,7 +131,10 @@ impl WinClip { if avail(self.fmt_rtf) { out.push(WIRE_RTF.to_string()); } - if avail(self.fmt_png) { + // Most apps put only the bitmap family on the clipboard (CF_DIB, from which Windows + // synthesizes CF_BITMAP/CF_DIBV5); browsers add the registered "PNG". Either serves a + // client image fetch — `read` converts DIB -> PNG when "PNG" itself is absent. + if avail(self.fmt_png) || avail(CF_DIB.0 as u32) { out.push(WIRE_PNG.to_string()); } out @@ -219,9 +222,16 @@ impl WinClip { /// Read one wire format of the current host selection (a client fetch). fn read(&self, wire: &str) -> anyhow::Result> { - let fmt = self + let mut fmt = self .format_for_wire(wire) .context("unsupported wire MIME")?; + // Image fetch with no native "PNG" on the clipboard (most apps): read CF_DIB and convert. + // SAFETY: IsClipboardFormatAvailable has no preconditions and needs no open clipboard. + let mut via_dib = false; + if wire == WIRE_PNG && unsafe { IsClipboardFormatAvailable(fmt) }.is_err() { + fmt = CF_DIB.0 as u32; + via_dib = true; + } // If we own the clipboard, its content is our own delayed-render offer (the client's copy), // not a host selection — declining avoids GetClipboardData re-entering our own WM_RENDERFORMAT. // SAFETY: GetClipboardOwner has no preconditions. @@ -246,6 +256,9 @@ impl WinClip { let _ = GlobalUnlock(hg); buf }; + if via_dib { + return winfmt::dib_to_png(&raw).context("CF_DIB -> PNG conversion failed"); + } Ok(convert_from_win(wire, &raw)) } @@ -267,7 +280,16 @@ impl WinClip { Ok(b) => b, Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste) }; - let win_bytes = convert_to_win(wire, &bytes); + let win_bytes = if fmt == CF_DIB.0 as u32 { + // The app asked for a bitmap: the client served PNG — convert. A PNG that doesn't + // decode leaves the format unrendered (empty paste), matching the timeout path. + match winfmt::png_to_dib(&bytes) { + Some(d) => d, + None => return, + } + } else { + convert_to_win(wire, &bytes) + }; let Ok(hg) = alloc_hglobal(&win_bytes) else { return; }; @@ -300,7 +322,7 @@ impl WinClip { Some(WIRE_HTML) } else if fmt == self.fmt_rtf { Some(WIRE_RTF) - } else if fmt == self.fmt_png { + } else if fmt == self.fmt_png || fmt == CF_DIB.0 as u32 { Some(WIRE_PNG) } else { None @@ -317,6 +339,12 @@ impl WinClip { out.push(f); } } + // Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients) + // ask for the bitmap family, not the registered "PNG"; Windows synthesizes + // CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` converts on demand. + if w == WIRE_PNG && !out.contains(&(CF_DIB.0 as u32)) { + out.push(CF_DIB.0 as u32); + } } out } diff --git a/crates/pf-clipboard/src/host/winfmt.rs b/crates/pf-clipboard/src/host/winfmt.rs index af78c2f2..e284bf8d 100644 --- a/crates/pf-clipboard/src/host/winfmt.rs +++ b/crates/pf-clipboard/src/host/winfmt.rs @@ -254,4 +254,113 @@ mod tests { // Only one NUL is stripped. assert_eq!(rtf_from_cf(b"x\0\0"), b"x\0"); } + + #[test] + fn png_dib_round_trip() { + // A 3x2 RGBA PNG with distinct corner pixels survives PNG -> DIB -> PNG. + let mut png = Vec::new(); + let img = image::RgbaImage::from_fn(3, 2, |x, y| { + image::Rgba([x as u8 * 40, y as u8 * 80, 200, 255]) + }); + image::DynamicImage::ImageRgba8(img.clone()) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let dib = png_to_dib(&png).expect("png -> dib"); + // BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp. + assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40); + assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3); + assert_eq!(i32::from_le_bytes(dib[8..12].try_into().unwrap()), 2); + let png2 = dib_to_png(&dib).expect("dib -> png"); + let back = image::load_from_memory(&png2).unwrap().to_rgba8(); + assert_eq!(back.dimensions(), (3, 2)); + assert_eq!(back.get_pixel(2, 1), img.get_pixel(2, 1)); + } + + #[test] + fn dib_to_png_rejects_garbage() { + assert!(dib_to_png(&[0u8; 10]).is_none()); + 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. + +/// PNG wire bytes → `CF_DIB` HGLOBAL bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). +/// `None` when the PNG doesn't decode — the caller leaves the format unrendered (empty paste). +pub fn png_to_dib(png: &[u8]) -> Option> { + let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).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) }