From 606c51a7b7e5b81d86f14f234599d38e9b0d3f3f Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 9 Jul 2026 18:31:36 +0200 Subject: [PATCH] fix(natives): decoded CF_DIB directly when arboard rejects an image arboard's Windows reader feeds Qt-style CF_DIBV5 payloads (BI_RGB plus alpha mask, rewritten to BI_BITFIELDS by its header tweak) to a header-less BMP decode that mis-places the pixel offset for V4/V5 bitfield headers, so PixPin/Snipaste screenshots failed with ConversionFailure. read_image_from_clipboard now falls back to reading the raw CF_DIB clipboard bytes and decoding them through the BMP file path with an explicit bfOffBits, keeping native Windows image paste off the PowerShell bridge. Fixes #3426 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/pi-natives/Cargo.toml | 3 +- crates/pi-natives/src/clipboard.rs | 239 ++++++++++++++++++++++++++++- packages/natives/CHANGELOG.md | 1 + 5 files changed, 242 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b29c8dce..87f1ebd9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2969,6 +2969,7 @@ dependencies = [ "ast-grep-core", "base64", "clap", + "clipboard-win", "flume", "fontdue", "globset", diff --git a/Cargo.toml b/Cargo.toml index 88c64391b..ecb8d8d92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -269,6 +269,7 @@ napi-derive = "3" # Terminal & PTY # ────────────────────────────────────────────────────────────────────────────── arboard = { version = "3.6.1", features = ["wayland-data-control"] } +clipboard-win = "5.4" icy_sixel = "0.5" portable-pty = "0.9" diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 6495a02c8..0f230bcd6 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -26,7 +26,7 @@ grep-searcher.workspace = true html-to-markdown-rs.workspace = true icy_sixel.workspace = true ignore.workspace = true -image.workspace = true +image = { workspace = true, features = ["bmp"] } inferno.workspace = true memmap2.workspace = true napi.workspace = true @@ -61,6 +61,7 @@ libc.workspace = true [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = ["Wdk_Storage_FileSystem", "Win32_Security"] } +clipboard-win.workspace = true winreg.workspace = true [build-dependencies] diff --git a/crates/pi-natives/src/clipboard.rs b/crates/pi-natives/src/clipboard.rs index 060cae6c7..74ba55c89 100644 --- a/crates/pi-natives/src/clipboard.rs +++ b/crates/pi-natives/src/clipboard.rs @@ -30,7 +30,11 @@ fn encode_png(image: ImageData<'_>) -> Result> { let bytes = image.bytes.into_owned(); let buffer = RgbaImage::from_raw(width, height, bytes) .ok_or_else(|| Error::from_reason("Clipboard image buffer size mismatch"))?; - let capacity = width.saturating_mul(height).saturating_mul(4) as usize; + rgba_to_png(buffer) +} + +fn rgba_to_png(buffer: RgbaImage) -> Result> { + let capacity = (buffer.width().saturating_mul(buffer.height()).saturating_mul(4)) as usize; let mut output = Vec::with_capacity(capacity); DynamicImage::ImageRgba8(buffer) .write_to(&mut Cursor::new(&mut output), ImageFormat::Png) @@ -38,6 +42,84 @@ fn encode_png(image: ImageData<'_>) -> Result> { Ok(output) } +/// Decode a packed DIB clipboard payload (`CF_DIB`: a `BITMAPINFOHEADER`-family +/// header, optional bitfield masks and palette, then the pixel array) into PNG +/// bytes. +/// +/// The payload is wrapped in a synthesized `BITMAPFILEHEADER` and decoded +/// through the BMP *file* path so the explicit `bfOffBits` pins the pixel +/// offset. This matters: the header-less decode path arboard uses mis-places +/// the pixel offset for V4/V5 headers with `BI_BITFIELDS` compression (it +/// skips 12 trailing mask bytes that those headers embed instead), which is +/// why Qt-based screenshot tools (PixPin, Snipaste, ...) fail through arboard +/// in the first place (#3426). +#[cfg_attr( + not(windows), + allow( + dead_code, + reason = "reached only by the Windows clipboard fallback; kept target-independent so unit tests cover it on every host" + ) +)] +fn dib_to_png(dib: &[u8]) -> Result> { + const FILE_HEADER_SIZE: u64 = 14; + const INFO_HEADER_SIZE: u64 = 40; + const BI_BITFIELDS: u32 = 3; + + if dib.len() < INFO_HEADER_SIZE as usize { + return Err(Error::from_reason("Clipboard DIB shorter than BITMAPINFOHEADER")); + } + let u32_at = + |at: usize| u32::from_le_bytes(dib[at..at + 4].try_into().expect("bounds checked above")); + let header_size = u64::from(u32_at(0)); + if header_size < INFO_HEADER_SIZE || header_size > dib.len() as u64 { + return Err(Error::from_reason("Clipboard DIB header size out of range")); + } + let bit_count = u16::from_le_bytes([dib[14], dib[15]]); + let compression = u32_at(16); + let colors_used = u64::from(u32_at(32)); + + // A plain BITMAPINFOHEADER with BI_BITFIELDS is trailed by three DWORD + // masks; larger (V2..V5) headers embed the masks in the header itself. + let mask_bytes: u64 = + if header_size == INFO_HEADER_SIZE && compression == BI_BITFIELDS { 12 } else { 0 }; + let palette_entries: u64 = if colors_used != 0 { + colors_used + } else if bit_count <= 8 { + 1u64 << bit_count + } else { + 0 + }; + let pixel_offset = + u32::try_from(FILE_HEADER_SIZE + header_size + mask_bytes + palette_entries * 4) + .map_err(|_| Error::from_reason("Clipboard DIB layout overflow"))?; + let file_size = u32::try_from(FILE_HEADER_SIZE + dib.len() as u64) + .map_err(|_| Error::from_reason("Clipboard DIB too large"))?; + + let mut bmp = Vec::with_capacity(FILE_HEADER_SIZE as usize + dib.len()); + bmp.extend_from_slice(b"BM"); + bmp.extend_from_slice(&file_size.to_le_bytes()); + bmp.extend_from_slice(&0u32.to_le_bytes()); + bmp.extend_from_slice(&pixel_offset.to_le_bytes()); + bmp.extend_from_slice(dib); + + let decoded = image::load_from_memory_with_format(&bmp, ImageFormat::Bmp) + .map_err(|err| Error::from_reason(format!("Failed to decode clipboard DIB: {err}")))?; + rgba_to_png(decoded.into_rgba8()) +} + +/// Read the raw `CF_DIB` bytes from the Windows clipboard. +/// +/// Windows synthesizes `CF_DIB` from whatever bitmap formats are present, so +/// it is available whenever the clipboard holds any image at all. +#[cfg(windows)] +fn read_raw_cf_dib() -> Option> { + let clip = clipboard_win::Clipboard::new_attempts(10).ok()?; + let mut dib = Vec::new(); + clipboard_win::raw::get_vec(clipboard_win::formats::CF_DIB, &mut dib).ok()?; + drop(clip); + (!dib.is_empty()).then_some(dib) +} + /// Copy plain text to the system clipboard. /// /// # Parameters @@ -120,7 +202,160 @@ pub fn read_image_from_clipboard() -> task::Promise> { })) }, Err(ClipboardError::ContentNotAvailable) => Ok(None), - Err(err) => Err(Error::from_reason(format!("Failed to read clipboard image: {err}"))), + Err(err) => { + // arboard rejects the CF_DIBV5 payloads Qt-based screenshot + // tools (PixPin, Snipaste, ...) produce; decode the raw CF_DIB + // ourselves before surfacing the error (#3426). A fallback + // decode failure keeps the original arboard error. + #[cfg(windows)] + if let Some(bytes) = read_raw_cf_dib().and_then(|dib| dib_to_png(&dib).ok()) { + return Ok(Some(ClipboardImage { + data: Uint8Array::from(bytes), + mime_type: "image/png".to_string(), + })); + } + Err(Error::from_reason(format!("Failed to read clipboard image: {err}"))) + }, } }) } + +#[cfg(test)] +mod tests { + use super::dib_to_png; + + fn push32(v: u32, out: &mut Vec) { + out.extend_from_slice(&v.to_le_bytes()); + } + + fn push16(v: u16, out: &mut Vec) { + out.extend_from_slice(&v.to_le_bytes()); + } + + /// 2x2 bottom-up BGRA pixel array: memory rows are [red, green] (bottom) + /// then [blue, white] (top), all with alpha 0xff. + const PIXELS_2X2: [u8; 16] = [ + 0x00, 0x00, 0xff, 0xff, // (0,1) red + 0x00, 0xff, 0x00, 0xff, // (1,1) green + 0xff, 0x00, 0x00, 0xff, // (0,0) blue + 0xff, 0xff, 0xff, 0xff, // (1,0) white + ]; + + /// `CF_DIB` as Qt's clipboard writer emits it for 32-bit content: a plain + /// `BITMAPINFOHEADER` with `BI_BITFIELDS` compression and three DWORD + /// masks between header and pixels. + fn qt_cf_dib(width: u32, height: u32, pixels_bgra: &[u8], compression: u32) -> Vec { + let mut d = Vec::with_capacity(52 + pixels_bgra.len()); + push32(40, &mut d); // biSize + push32(width, &mut d); + push32(height, &mut d); // positive: bottom-up + push16(1, &mut d); // biPlanes + push16(32, &mut d); // biBitCount + push32(compression, &mut d); + push32(pixels_bgra.len() as u32, &mut d); // biSizeImage + push32(0, &mut d); // biXPelsPerMeter + push32(0, &mut d); // biYPelsPerMeter + push32(0, &mut d); // biClrUsed + push32(0, &mut d); // biClrImportant + if compression == 3 { + push32(0x00ff_0000, &mut d); // red mask + push32(0x0000_ff00, &mut d); // green mask + push32(0x0000_00ff, &mut d); // blue mask + } + d.extend_from_slice(pixels_bgra); + d + } + + /// `CF_DIBV5` as PixPin (Qt) places it, after arboard's + /// `maybe_tweak_header` rewrite: a 124-byte `BITMAPV5HEADER` carrying + /// `BI_BITFIELDS` compression with the BGRA masks embedded in the header + /// and pixels immediately after it. This is the exact buffer shape that + /// arboard's header-less BMP decode rejects with `ConversionFailure` + /// (issue #3426); the file-header wrap must decode it. + fn pixpin_dibv5_tweaked(width: u32, height: u32, pixels_bgra: &[u8]) -> Vec { + let mut d = Vec::with_capacity(124 + pixels_bgra.len()); + push32(124, &mut d); // bV5Size + push32(width, &mut d); + push32(height, &mut d); + push16(1, &mut d); // bV5Planes + push16(32, &mut d); // bV5BitCount + push32(3, &mut d); // bV5Compression = BI_BITFIELDS (arboard-tweaked) + push32(0, &mut d); // bV5SizeImage + push32(0, &mut d); // bV5XPelsPerMeter + push32(0, &mut d); // bV5YPelsPerMeter + push32(0, &mut d); // bV5ClrUsed + push32(0, &mut d); // bV5ClrImportant + push32(0x00ff_0000, &mut d); // bV5RedMask + push32(0x0000_ff00, &mut d); // bV5GreenMask + push32(0x0000_00ff, &mut d); // bV5BlueMask + push32(0xff00_0000, &mut d); // bV5AlphaMask + push32(0x7352_4742, &mut d); // bV5CSType = LCS_sRGB + d.extend_from_slice(&[0u8; 36]); // bV5Endpoints + push32(0, &mut d); // bV5GammaRed + push32(0, &mut d); // bV5GammaGreen + push32(0, &mut d); // bV5GammaBlue + push32(4, &mut d); // bV5Intent = LCS_GM_IMAGES + push32(0, &mut d); // bV5ProfileData + push32(0, &mut d); // bV5ProfileSize + push32(0, &mut d); // bV5Reserved + assert_eq!(d.len(), 124); + d.extend_from_slice(pixels_bgra); + d + } + + fn decode_pixels(png: &[u8]) -> (u32, u32, Vec<[u8; 4]>) { + let img = image::load_from_memory(png).expect("fallback output must be valid PNG"); + let rgba = img.into_rgba8(); + let (w, h) = rgba.dimensions(); + let px = rgba.pixels().map(|p| p.0).collect(); + (w, h, px) + } + + const RED: [u8; 4] = [255, 0, 0, 255]; + const GREEN: [u8; 4] = [0, 255, 0, 255]; + const BLUE: [u8; 4] = [0, 0, 255, 255]; + const WHITE: [u8; 4] = [255, 255, 255, 255]; + + #[test] + fn decodes_qt_cf_dib_with_bitfields_masks() { + let dib = qt_cf_dib(2, 2, &PIXELS_2X2, 3); + let png = dib_to_png(&dib).expect("BI_BITFIELDS CF_DIB must decode"); + let (w, h, px) = decode_pixels(&png); + assert_eq!((w, h), (2, 2)); + // Row order flipped versus the bottom-up pixel array; BGRA -> RGBA. + assert_eq!(px, vec![BLUE, WHITE, RED, GREEN]); + } + + #[test] + fn decodes_pixpin_dibv5_payload_that_arboard_rejects() { + let dib = pixpin_dibv5_tweaked(2, 2, &PIXELS_2X2); + let png = dib_to_png(&dib).expect("V5 BI_BITFIELDS DIB must decode"); + let (w, h, px) = decode_pixels(&png); + assert_eq!((w, h), (2, 2)); + assert_eq!(px, vec![BLUE, WHITE, RED, GREEN]); + } + + #[test] + fn decodes_plain_bi_rgb_dib() { + // The common "copy image" payload: BI_RGB, 32-bit, no masks. The + // fourth byte is unused per the DIB contract — zero it to prove the + // decode still yields opaque pixels. + let mut pixels = PIXELS_2X2; + for alpha in pixels.iter_mut().skip(3).step_by(4) { + *alpha = 0; + } + let dib = qt_cf_dib(2, 2, &pixels, 0); + let png = dib_to_png(&dib).expect("BI_RGB CF_DIB must decode"); + let (w, h, px) = decode_pixels(&png); + assert_eq!((w, h), (2, 2)); + assert_eq!(px, vec![BLUE, WHITE, RED, GREEN]); + } + + #[test] + fn rejects_malformed_dib() { + assert!(dib_to_png(&[0u8; 12]).is_err(), "short buffer must not decode"); + let mut oversized_header = qt_cf_dib(2, 2, &PIXELS_2X2, 3); + oversized_header[0..4].copy_from_slice(&0xffff_ffffu32.to_le_bytes()); + assert!(dib_to_png(&oversized_header).is_err(), "header size beyond buffer must not decode"); + } +} diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 58027bbd9..d65f4e4cc 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed unbounded memory growth in the native bash output bridge when a command produces output faster than the JS event loop consumes it: the shell streaming path now uses a bounded chunk queue with real backpressure (pipe readers park until the JS callback catches up, parking the child on its pipe) instead of buffering the entire surplus in memory. No output is dropped — the rolling tail view, `[raw output: artifact://…]` lossless capture, and byte accounting are unaffected ([#4078](https://github.com/can1357/oh-my-pi/issues/4078)). +- Fixed `readImageFromClipboard` on Windows failing with "could not be converted to the appropriate format" for screenshots taken by Qt-based tools such as PixPin and Snipaste. arboard hands their `CF_DIBV5` payload (`BI_RGB` plus an alpha mask, rewritten to `BI_BITFIELDS`) to a header-less BMP decode that mis-places the pixel offset for V4/V5 bitfield headers; the native reader now falls back to decoding the raw `CF_DIB` clipboard bytes directly, so image paste no longer depends on the PowerShell bridge. ([#3426](https://github.com/can1357/oh-my-pi/issues/3426)) ## [16.3.12] - 2026-07-08