Browse / github.com/storytold/deckcraft / patch
Fix Windows clipboard image paste blocker (CF_DIB / CF_DIBV5 decoding)
Fix Windows clipboard image paste blocker (CF_DIB / CF_DIBV5 decoding) ## Summary Resolves #53 and #763 by implementing native Windows clipboard bitmap ingestion (`CF_DIB`, `CF_DIBV5`, and registered `PNG`/`image/png` formats) and enabling image paste via Ctrl+V as well as Ribbon/Context/Edit menu Paste commands. ## Background & Root Cause On Windows, pasting copied screenshots (Snipping Tool, `Win + Shift + S`, PrintScreen) or browser-copied images into DeckCraft failed: 1. `arboard` only attempts reading registered format `PNG` and `CF_DIBV5`. Most Windows applications and screenshots place `CF_DIB` (standard 40-byte `BITMAPINFOHEADER`) on the clipboard, which
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- boycececil666gmailcom (imported from pull request #1057, open)
- Base
f34f4121c8ee720ca08e5d47b912155a33504e69- Size
- +816 −6 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4bfc4bf62be93be10e88fb74
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock Cargo.toml apps/deckcraft/Cargo.toml apps/deckcraft/src/main.rs apps/deckcraft/src/pasteboard.rs crates/ui-egui/src/lib.rs
@@ -895,6 +895,7 @@ name = "deckcraft" version = "0.8.0" dependencies = [ "arboard",+ "clipboard-win", "cpal", "deckcraft-engine", "deckcraft-media",@@ -69,6 +69,7 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" arboard = { version = "3.6", default-features = false }+clipboard-win = "5.4" [workspace.lints.rust] unsafe_code = "forbid"@@ -31,3 +31,6 @@ cpal = "0.16" [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] winit = { version = "0.30", default-features = false, features = ["x11"] }++[target.'cfg(windows)'.dependencies]+clipboard-win = { workspace = true }@@ -114,6 +114,7 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),+ clipboard_text: Some(Box::new(pasteboard::text)), clipboard_image: Some(Box::new(pasteboard::image_png)), clipboard_html: Some(Box::new(pasteboard::html)), clipboard_set_html: Some(Box::new(pasteboard::set_html)),@@ -1,8 +1,13 @@-//! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard).+//! System pasteboard: HTML rich text, and pictures. //!-//! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain-//! words. A shape with no text is copied as an image by the UI; this module only reads a picture-//! back (Keynote's TIFF) as PNG bytes.+//! On Windows, decodes native clipboard bitmaps (CF_DIB / CF_DIBV5) as well as registered PNG+//! payloads into PNG byte buffers. On macOS, reads Keynote/Pages TIFF pictures decoded by arboard.++/// Plain text currently on the pasteboard, when there is some.+pub fn text() -> Option<String> {+ let mut cb = arboard::Clipboard::new().ok()?;+ cb.get_text().ok()+} /// HTML currently on the pasteboard, when there is some. pub fn html() -> Option<String> {@@ -18,8 +23,24 @@ pub fn set_html(html: &str, text: &str) -> bool { cb.set_html(html, Some(text)).is_ok() } -/// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard).+/// The pasteboard picture as PNG bytes.+///+/// On Windows, queries the native clipboard for registered "PNG", `CF_DIBV5`, and `CF_DIB`,+/// decoding device-independent bitmaps to valid PNG byte buffers while handling BGR/BGRA channel+/// orders, row padding, bottom-up/top-down orientations, and GDI zero-alpha quirks.+/// Falls back to arboard if native formats are absent. pub fn image_png() -> Option<Vec<u8>> {+ #[cfg(target_os = "windows")]+ {+ if let Some(bytes) = windows_image_png() {+ return Some(bytes);+ }+ }+ arboard_image_png()+}++/// Decodes an image from the pasteboard via arboard as fallback.+fn arboard_image_png() -> Option<Vec<u8>> { let mut cb = arboard::Clipboard::new().ok()?; let img = cb.get_image().ok()?; let width = u32::try_from(img.width).ok()?;@@ -30,10 +51,715 @@ pub fn image_png() -> Option<Vec<u8>> { Some(out) } +/// Queries native Windows clipboard formats (registered PNG, CF_DIBV5, CF_DIB) and decodes them.+#[cfg(target_os = "windows")]+fn windows_image_png() -> Option<Vec<u8>> {+ let _clip = clipboard_win::Clipboard::new_attempts(10).ok()?;++ // 1. Registered format "PNG" or "image/png" (written by Chrome, Edge, modern Snipping Tool).+ for name in ["PNG", "image/png"] {+ if let Some(id) = clipboard_win::raw::register_format(name).map(u32::from)+ && clipboard_win::is_format_avail(id)+ {+ let mut data = Vec::new();+ if clipboard_win::raw::get_vec(id, &mut data).is_ok()+ && !data.is_empty()+ && image::load_from_memory_with_format(&data, image::ImageFormat::Png).is_ok()+ {+ return Some(data);+ }+ }+ }++ // 2. CF_DIBV5 (Device Independent Bitmap Version 5, format 17).+ if clipboard_win::is_format_avail(clipboard_win::formats::CF_DIBV5) {+ let mut data = Vec::new();+ if clipboard_win::raw::get_vec(clipboard_win::formats::CF_DIBV5, &mut data).is_ok()+ && !data.is_empty()+ && let Some(png) = decode_dib_to_png(&data)+ {+ return Some(png);+ }+ }++ // 3. CF_DIB (Standard Device Independent Bitmap, format 8).+ if clipboard_win::is_format_avail(clipboard_win::formats::CF_DIB) {+ let mut data = Vec::new();+ if clipboard_win::raw::get_vec(clipboard_win::formats::CF_DIB, &mut data).is_ok()+ && !data.is_empty()+ && let Some(png) = decode_dib_to_png(&data)+ {+ return Some(png);+ }+ }++ None+}++/// Decodes raw CF_DIB or CF_DIBV5 bytes from the Windows clipboard into PNG bytes.+#[cfg(any(target_os = "windows", test))]+pub fn decode_dib_to_png(data: &[u8]) -> Option<Vec<u8>> {+ let rgba = decode_dib(data)?;+ let mut out = Vec::new();+ rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;+ Some(out)+}++/// Decodes raw CF_DIB or CF_DIBV5 bytes into an RGBA image.+#[cfg(any(target_os = "windows", test))]+pub fn decode_dib(data: &[u8]) -> Option<image::RgbaImage> {+ if data.len() < 40 {+ return None;+ }+ let header_size = u32::from_le_bytes(data[0..4].try_into().ok()?) as usize;+ if header_size < 40 || header_size > data.len() {+ return None;+ }++ let width = i32::from_le_bytes(data[4..8].try_into().ok()?);+ let height = i32::from_le_bytes(data[8..12].try_into().ok()?);+ let planes = u16::from_le_bytes(data[12..14].try_into().ok()?);+ let bit_count = u16::from_le_bytes(data[14..16].try_into().ok()?);+ let compression = u32::from_le_bytes(data[16..20].try_into().ok()?);+ let clr_used = u32::from_le_bytes(data[32..36].try_into().ok()?);++ if width <= 0 || width > 65535 || height == 0 || height.unsigned_abs() > 65535 || planes != 1 || !matches!(bit_count, 1 | 4 | 8 | 16 | 24 | 32) {+ return None;+ }++ let declared_alpha = has_declared_alpha_mask(data, header_size, bit_count, compression);++ // Try standard BMP decoding by synthesizing a 14-byte BITMAPFILEHEADER.+ if let Some(mut img) = decode_via_bmp_file(data, header_size, bit_count, compression, clr_used) {+ if !declared_alpha {+ fix_alpha_if_all_transparent(&mut img);+ }+ return Some(img);+ }++ // Fallback: direct uncompressed DIB decoding (handles 24-bit and 32-bit BI_RGB / BI_BITFIELDS).+ let mask_size = if header_size == 40 && compression == 3 {+ 12+ } else if header_size == 40 && compression == 6 {+ 16+ } else {+ 0+ };+ let palette_colors = if bit_count <= 8 {+ let max_colors = 1usize << bit_count;+ if clr_used != 0 { (clr_used as usize).min(max_colors) } else { max_colors }+ } else {+ if clr_used > 65536 {+ return None;+ }+ clr_used as usize+ };+ let palette_size = palette_colors.checked_mul(4)?;+ let offset_to_bits = header_size.checked_add(mask_size)?.checked_add(palette_size)?;+ if offset_to_bits > data.len() {+ return None;+ }++ if (bit_count == 24 || bit_count == 32)+ && (compression == 0 || compression == 3)+ && let Some(mut img) = decode_uncompressed_dib(data, header_size, width, height, bit_count, compression, offset_to_bits, declared_alpha)+ {+ if !declared_alpha {+ fix_alpha_if_all_transparent(&mut img);+ }+ return Some(img);+ }++ None+}++/// Returns true if the DIB header declares an explicit non-zero alpha mask.+#[cfg(any(target_os = "windows", test))]+fn has_declared_alpha_mask(data: &[u8], header_size: usize, bit_count: u16, compression: u32) -> bool {+ if bit_count != 32 {+ return false;+ }+ // DIBV4 (108 bytes) and DIBV5 (124 bytes) declare bV4AlphaMask / bV5AlphaMask at offset 52..56.+ if header_size >= 56+ && data.len() >= 56+ && let Ok(bytes) = data[52..56].try_into()+ && u32::from_le_bytes(bytes) != 0+ {+ return true;+ }+ // Standard 40-byte BITMAPINFOHEADER with BI_BITFIELDS (3) or BI_ALPHABITFIELDS (6)+ // where an alpha mask is provided after the color table / masks.+ if header_size == 40+ && (compression == 3 || compression == 6)+ && data.len() >= 56+ && let Ok(bytes) = data[52..56].try_into()+ && u32::from_le_bytes(bytes) != 0+ {+ return true;+ }+ false+}++/// Synthesizes a BMP file header and decodes DIB data using the image crate's BMP decoder.+#[cfg(any(target_os = "windows", test))]+fn decode_via_bmp_file(data: &[u8], header_size: usize, bit_count: u16, compression: u32, clr_used: u32) -> Option<image::RgbaImage> {+ let mask_size = if header_size == 40 && compression == 3 {+ 12+ } else if header_size == 40 && compression == 6 {+ 16+ } else {+ 0+ };+ let palette_colors = if bit_count <= 8 {+ let max_colors = 1usize << bit_count;+ if clr_used != 0 { (clr_used as usize).min(max_colors) } else { max_colors }+ } else {+ if clr_used > 65536 {+ return None;+ }+ clr_used as usize+ };+ let palette_size = palette_colors.checked_mul(4)?;+ let offset_to_bits = header_size.checked_add(mask_size)?.checked_add(palette_size)?;+ if offset_to_bits > data.len() {+ return None;+ }+ let bf_off_bits = u32::try_from(14usize.checked_add(offset_to_bits)?).ok()?;+ let bf_size = u32::try_from(14usize.checked_add(data.len())?).ok()?;++ let mut bmp = Vec::with_capacity(14 + data.len());+ bmp.extend_from_slice(b"BM");+ bmp.extend_from_slice(&bf_size.to_le_bytes());+ bmp.extend_from_slice(&[0u8; 4]);+ bmp.extend_from_slice(&bf_off_bits.to_le_bytes());+ bmp.extend_from_slice(data);++ // If DIBV4/DIBV5 has 32-bit BI_RGB but carries an alpha mask (Chrome/Electron),+ // tweak compression to BI_BITFIELDS so the BMP decoder reads the alpha channel.+ if header_size >= 108 && bit_count == 32 && compression == 0 && data.len() >= 56 {+ let alpha_mask = u32::from_le_bytes(data[52..56].try_into().ok()?);+ if alpha_mask == 0xff000000 {+ // offset 14 + 16 is biCompression in the BMP file+ bmp[14 + 16..14 + 20].copy_from_slice(&3u32.to_le_bytes());+ let r_mask = u32::from_le_bytes(data[40..44].try_into().ok()?);+ let g_mask = u32::from_le_bytes(data[44..48].try_into().ok()?);+ let b_mask = u32::from_le_bytes(data[48..52].try_into().ok()?);+ if r_mask == 0 && g_mask == 0 && b_mask == 0 {+ bmp[14 + 40..14 + 44].copy_from_slice(&0x00ff0000u32.to_le_bytes());+ bmp[14 + 44..14 + 48].copy_from_slice(&0x0000ff00u32.to_le_bytes());+ bmp[14 + 48..14 + 52].copy_from_slice(&0x000000ffu32.to_le_bytes());+ }+ }+ }++ let dyn_img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?;+ Some(dyn_img.to_rgba8())+}++/// Restores alpha to 255 if all pixels in an RGBA image have zero alpha (GDI zero-alpha quirk).+#[cfg(any(target_os = "windows", test))]+fn fix_alpha_if_all_transparent(rgba: &mut image::RgbaImage) {+ if rgba.pixels().all(|p| p[3] == 0) {+ for p in rgba.pixels_mut() {+ p[3] = 255;+ }+ }+}++/// Fallback direct decoder for uncompressed 24-bit and 32-bit DIB bitmaps.+#[cfg(any(target_os = "windows", test))]+fn decode_uncompressed_dib(+ data: &[u8],+ header_size: usize,+ width: i32,+ height: i32,+ bit_count: u16,+ compression: u32,+ offset_to_bits: usize,+ declared_alpha: bool,+) -> Option<image::RgbaImage> {+ let w = u32::try_from(width).ok()?;+ let h = height.unsigned_abs();+ let top_down = height < 0;++ let bytes_per_pixel = usize::from(bit_count / 8);+ let row_stride = (w as usize).checked_mul(bytes_per_pixel)?.div_ceil(4).checked_mul(4)?;+ let total_pixel_bytes = row_stride.checked_mul(h as usize)?;++ if offset_to_bits > data.len() {+ return None;+ }+ let bits = &data[offset_to_bits..];+ if bits.len() < total_pixel_bytes {+ return None;+ }++ let mut img = image::RgbaImage::new(w, h);+ let mut has_non_zero_alpha = false;++ let (r_shift, g_shift, b_shift, a_shift) = if bit_count == 32 && compression == 3 {+ let (rm, gm, bm, am) = if header_size >= 56 {+ let rm = u32::from_le_bytes(data[40..44].try_into().ok()?);+ let gm = u32::from_le_bytes(data[44..48].try_into().ok()?);+ let bm = u32::from_le_bytes(data[48..52].try_into().ok()?);+ let am = u32::from_le_bytes(data[52..56].try_into().ok()?);+ (rm, gm, bm, am)+ } else if data.len() >= 52 {+ let rm = u32::from_le_bytes(data[40..44].try_into().ok()?);+ let gm = u32::from_le_bytes(data[44..48].try_into().ok()?);+ let bm = u32::from_le_bytes(data[48..52].try_into().ok()?);+ let am = if data.len() >= 56 { u32::from_le_bytes(data[52..56].try_into().unwrap_or([0; 4])) } else { 0 };+ (rm, gm, bm, am)+ } else {+ (0x00ff0000, 0x0000ff00, 0x000000ff, 0)+ };+ (mask_shift(rm), mask_shift(gm), mask_shift(bm), if am != 0 { Some(mask_shift(am)) } else { None })+ } else if bit_count == 32 && declared_alpha && data.len() >= 56 {+ let am = u32::from_le_bytes(data[52..56].try_into().ok()?);+ (16, 8, 0, (am != 0).then(|| mask_shift(am)))+ } else {+ (16, 8, 0, None)+ };++ for y in 0..h {+ let src_y = if top_down { y as usize } else { (h - 1 - y) as usize };+ let row_start = src_y * row_stride;+ let row_bytes = &bits[row_start..row_start + row_stride];++ for x in 0..w {+ let px_offset = (x as usize) * bytes_per_pixel;+ let (r, g, b, a) = if bit_count == 32 {+ let val = u32::from_le_bytes(row_bytes[px_offset..px_offset + 4].try_into().ok()?);+ let r = ((val >> r_shift) & 0xff) as u8;+ let g = ((val >> g_shift) & 0xff) as u8;+ let b = ((val >> b_shift) & 0xff) as u8;+ let a = if let Some(asft) = a_shift {+ let a_val = ((val >> asft) & 0xff) as u8;+ if a_val != 0 {+ has_non_zero_alpha = true;+ }+ a_val+ } else {+ 255+ };+ (r, g, b, a)+ } else {+ let b = row_bytes[px_offset];+ let g = row_bytes[px_offset + 1];+ let r = row_bytes[px_offset + 2];+ (r, g, b, 255)+ };+ img.put_pixel(x, y, image::Rgba([r, g, b, a]));+ }+ }++ if bit_count == 32 && a_shift.is_some() && !has_non_zero_alpha && !declared_alpha {+ for p in img.pixels_mut() {+ p[3] = 255;+ }+ }++ Some(img)+}++/// Computes the bit shift for a bitfield mask.+#[cfg(any(target_os = "windows", test))]+fn mask_shift(mask: u32) -> u32 {+ if mask == 0 {+ return 0;+ }+ mask.trailing_zeros()+}+ #[cfg(test)] mod tests { use std::borrow::Cow; + /// Constructs a test CF_DIB buffer with 24-bit RGB depth.+ fn make_cf_dib_24(width: i32, height: i32, bgr: &[u8; 3]) -> Vec<u8> {+ let w = width as usize;+ let h = height.unsigned_abs() as usize;+ let row_stride = (w * 3).div_ceil(4) * 4;+ let mut dib = vec![0u8; 40 + row_stride * h];+ dib[0..4].copy_from_slice(&40u32.to_le_bytes());+ dib[4..8].copy_from_slice(&width.to_le_bytes());+ dib[8..12].copy_from_slice(&height.to_le_bytes());+ dib[12..14].copy_from_slice(&1u16.to_le_bytes());+ dib[14..16].copy_from_slice(&24u16.to_le_bytes());+ dib[16..20].copy_from_slice(&0u32.to_le_bytes());+ dib[20..24].copy_from_slice(&(row_stride as u32 * h as u32).to_le_bytes());++ for y in 0..h {+ let row_start = 40 + y * row_stride;+ for x in 0..w {+ let px = row_start + x * 3;+ dib[px..px + 3].copy_from_slice(bgr);+ }+ }+ dib+ }++ /// Constructs a test CF_DIB buffer with 32-bit RGBA depth.+ fn make_cf_dib_32(width: i32, height: i32, bgra: &[u8; 4]) -> Vec<u8> {+ let w = width as usize;+ let h = height.unsigned_abs() as usize;+ let row_stride = w * 4;+ let mut dib = vec![0u8; 40 + row_stride * h];+ dib[0..4].copy_from_slice(&40u32.to_le_bytes());+ dib[4..8].copy_from_slice(&width.to_le_bytes());+ dib[8..12].copy_from_slice(&height.to_le_bytes());+ dib[12..14].copy_from_slice(&1u16.to_le_bytes());+ dib[14..16].copy_from_slice(&32u16.to_le_bytes());+ dib[16..20].copy_from_slice(&0u32.to_le_bytes());+ dib[20..24].copy_from_slice(&(row_stride as u32 * h as u32).to_le_bytes());++ for y in 0..h {+ let row_start = 40 + y * row_stride;+ for x in 0..w {+ let px = row_start + x * 4;+ dib[px..px + 4].copy_from_slice(bgra);+ }+ }+ dib+ }++ /// Constructs a test CF_DIBV5 buffer with 32-bit depth and explicit alpha mask.+ fn make_cf_dibv5_32(width: i32, height: i32, bgra: &[u8; 4], alpha_mask: u32) -> Vec<u8> {+ let w = width as usize;+ let h = height.unsigned_abs() as usize;+ let row_stride = w * 4;+ let mut dib = vec![0u8; 124 + row_stride * h];+ dib[0..4].copy_from_slice(&124u32.to_le_bytes());+ dib[4..8].copy_from_slice(&width.to_le_bytes());+ dib[8..12].copy_from_slice(&height.to_le_bytes());+ dib[12..14].copy_from_slice(&1u16.to_le_bytes());+ dib[14..16].copy_from_slice(&32u16.to_le_bytes());+ let comp = if alpha_mask != 0 { 3u32 } else { 0u32 };+ dib[16..20].copy_from_slice(&comp.to_le_bytes());+ dib[20..24].copy_from_slice(&(row_stride as u32 * h as u32).to_le_bytes());+ dib[40..44].copy_from_slice(&0x00ff0000u32.to_le_bytes());+ dib[44..48].copy_from_slice(&0x0000ff00u32.to_le_bytes());+ dib[48..52].copy_from_slice(&0x000000ffu32.to_le_bytes());+ dib[52..56].copy_from_slice(&alpha_mask.to_le_bytes());++ for y in 0..h {+ let row_start = 124 + y * row_stride;+ for x in 0..w {+ let px = row_start + x * 4;+ dib[px..px + 4].copy_from_slice(bgra);+ }+ }+ dib+ }++ /// Verifies that 24-bit CF_DIB bitmaps decode to valid PNG images.+ #[test]+ fn cf_dib_24bit_decodes_to_valid_png() {+ // Red pixel in BGR is [0, 0, 255]+ let dib = make_cf_dib_24(3, 2, &[0, 0, 255]);+ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.width(), 3);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(1, 1);+ assert_eq!(p[0], 255);+ assert_eq!(p[1], 0);+ assert_eq!(p[2], 0);+ assert_eq!(p[3], 255);+ }++ /// Verifies that 32-bit CF_DIB bitmaps with all-zero alpha (GDI quirk) become opaque.+ #[test]+ fn cf_dib_32bit_with_zero_alpha_becomes_opaque() {+ // Snipping Tool / GDI quirk: 32-bit BGRx with alpha = 0 for every pixel.+ let dib = make_cf_dib_32(2, 2, &[255, 128, 64, 0]);+ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.width(), 2);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(0, 0);+ assert_eq!(p[0], 64);+ assert_eq!(p[1], 128);+ assert_eq!(p[2], 255);+ assert_eq!(p[3], 255, "zero alpha should be restored to fully opaque");+ }++ /// Verifies that 32-bit CF_DIBV5 bitmaps with explicit alpha masks preserve genuine transparency.+ #[test]+ fn cf_dibv5_32bit_with_alpha_mask_preserves_transparency() {+ // Chrome / modern browser quirk: BITMAPV5HEADER with alpha mask and semi-transparent alpha.+ let dib = make_cf_dibv5_32(2, 2, &[10, 20, 30, 180], 0xff000000);+ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.width(), 2);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(0, 0);+ assert_eq!(p[0], 30);+ assert_eq!(p[1], 20);+ assert_eq!(p[2], 10);+ assert_eq!(p[3], 180, "alpha channel should be preserved");+ }++ /// Verifies that 32-bit CF_DIBV5 bitmaps declaring an explicit alpha mask preserve zero alpha.+ #[test]+ fn cf_dibv5_32bit_with_alpha_mask_and_all_zero_alpha_preserves_transparency() {+ let dib = make_cf_dibv5_32(2, 2, &[10, 20, 30, 0], 0xff000000);+ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.width(), 2);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(0, 0);+ assert_eq!(p[0], 30);+ assert_eq!(p[1], 20);+ assert_eq!(p[2], 10);+ assert_eq!(p[3], 0, "explicit alpha mask with zero alpha must preserve transparency");+ }++ /// Verifies that 32-bit CF_DIBV5 bitmaps without an alpha mask (alpha_mask == 0) and zero alpha become opaque.+ #[test]+ fn cf_dibv5_32bit_without_alpha_mask_and_zero_alpha_becomes_opaque() {+ let dib = make_cf_dibv5_32(2, 2, &[255, 128, 64, 0], 0);+ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.get_pixel(0, 0)[3], 255);+ }++ /// Verifies that top-down DIB bitmaps with negative height decode with correct row order.+ #[test]+ fn top_down_dib_decodes_with_correct_orientation() {+ // Top-down: negative height+ let width: i32 = 2;+ let height: i32 = -2;+ let w = width as usize;+ let h = 2usize;+ let row_stride = w * 4;+ let mut dib = vec![0u8; 40 + row_stride * h];+ dib[0..4].copy_from_slice(&40u32.to_le_bytes());+ dib[4..8].copy_from_slice(&width.to_le_bytes());+ dib[8..12].copy_from_slice(&height.to_le_bytes());+ dib[12..14].copy_from_slice(&1u16.to_le_bytes());+ dib[14..16].copy_from_slice(&32u16.to_le_bytes());++ // Top row: red [0, 0, 255, 255]+ for x in 0..w {+ let px = 40 + x * 4;+ dib[px..px + 4].copy_from_slice(&[0, 0, 255, 255]);+ }+ // Bottom row: blue [255, 0, 0, 255]+ for x in 0..w {+ let px = 40 + row_stride + x * 4;+ dib[px..px + 4].copy_from_slice(&[255, 0, 0, 255]);+ }++ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ let top = decoded.get_pixel(0, 0);+ let bottom = decoded.get_pixel(0, 1);+ assert_eq!(top[0], 255, "top row is red");+ assert_eq!(bottom[2], 255, "bottom row is blue");+ }++ /// Verifies that truncated or invalid DIB headers return None.+ #[test]+ fn invalid_or_truncated_dib_returns_none() {+ assert!(super::decode_dib_to_png(&[]).is_none());+ assert!(super::decode_dib_to_png(&[0u8; 10]).is_none());+ assert!(super::decode_dib_to_png(&[40, 0, 0, 0]).is_none());+ }++ /// Verifies that minimal 1x1 CF_DIBV5 bitmaps decode successfully.+ #[test]+ fn small_1x1_dibv5_decodes_successfully() {+ let dib = make_cf_dibv5_32(1, 1, &[10, 20, 30, 255], 0xff000000);+ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.width(), 1);+ assert_eq!(decoded.height(), 1);+ let p = decoded.get_pixel(0, 0);+ assert_eq!(p[0], 30);+ assert_eq!(p[1], 20);+ assert_eq!(p[2], 10);+ assert_eq!(p[3], 255);+ }++ /// Verifies that 16-bit 5:6:5 BI_BITFIELDS DIB bitmaps decode to valid PNG images.+ #[test]+ fn cf_dib_16bit_565_decodes_to_valid_png() {+ let width: i32 = 2;+ let height: i32 = 2;+ let w = width as usize;+ let h = height as usize;+ let row_stride = (w * 2).div_ceil(4) * 4;+ let mut dib = vec![0u8; 40 + 12 + row_stride * h];+ dib[0..4].copy_from_slice(&40u32.to_le_bytes());+ dib[4..8].copy_from_slice(&width.to_le_bytes());+ dib[8..12].copy_from_slice(&height.to_le_bytes());+ dib[12..14].copy_from_slice(&1u16.to_le_bytes());+ dib[14..16].copy_from_slice(&16u16.to_le_bytes());+ dib[16..20].copy_from_slice(&3u32.to_le_bytes()); // BI_BITFIELDS+ dib[20..24].copy_from_slice(&(row_stride as u32 * h as u32).to_le_bytes());+ // Masks: Red 0xF800, Green 0x07E0, Blue 0x001F+ dib[40..44].copy_from_slice(&0x0000f800u32.to_le_bytes());+ dib[44..48].copy_from_slice(&0x000007e0u32.to_le_bytes());+ dib[48..52].copy_from_slice(&0x0000001fu32.to_le_bytes());++ // Red pixels (0xF800)+ for y in 0..h {+ let row_start = 52 + y * row_stride;+ for x in 0..w {+ let px = row_start + x * 2;+ dib[px..px + 2].copy_from_slice(&0xf800u16.to_le_bytes());+ }+ }++ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.width(), 2);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(0, 0);+ assert!(p[0] > 240 && p[1] < 10 && p[2] < 10 && p[3] == 255, "red pixel: {p:?}");+ }++ /// Verifies that 16-bit 5:5:5 BI_RGB DIB bitmaps decode to valid PNG images.+ #[test]+ fn cf_dib_16bit_555_decodes_to_valid_png() {+ let width: i32 = 2;+ let height: i32 = 2;+ let w = width as usize;+ let h = height as usize;+ let row_stride = (w * 2).div_ceil(4) * 4;+ let mut dib = vec![0u8; 40 + row_stride * h];+ dib[0..4].copy_from_slice(&40u32.to_le_bytes());+ dib[4..8].copy_from_slice(&width.to_le_bytes());+ dib[8..12].copy_from_slice(&height.to_le_bytes());+ dib[12..14].copy_from_slice(&1u16.to_le_bytes());+ dib[14..16].copy_from_slice(&16u16.to_le_bytes());+ dib[16..20].copy_from_slice(&0u32.to_le_bytes()); // BI_RGB+ dib[20..24].copy_from_slice(&(row_stride as u32 * h as u32).to_le_bytes());++ // Green pixels in 5:5:5 (bits 5..10: 0x03E0)+ for y in 0..h {+ let row_start = 40 + y * row_stride;+ for x in 0..w {+ let px = row_start + x * 2;+ dib[px..px + 2].copy_from_slice(&0x03e0u16.to_le_bytes());+ }+ }++ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.width(), 2);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(0, 0);+ assert!(p[0] < 10 && p[1] > 240 && p[2] < 10 && p[3] == 255, "green pixel: {p:?}");+ }++ /// Verifies that 8-bit paletted DIB bitmaps decode with correct palette lookup.+ #[test]+ fn cf_dib_8bit_paletted_decodes_to_valid_png() {+ let width: i32 = 2;+ let height: i32 = 2;+ let w = width as usize;+ let h = height as usize;+ let row_stride = w.div_ceil(4) * 4;+ let clr_used = 2u32;+ let palette_size = clr_used as usize * 4;+ let mut dib = vec![0u8; 40 + palette_size + row_stride * h];+ dib[0..4].copy_from_slice(&40u32.to_le_bytes());+ dib[4..8].copy_from_slice(&width.to_le_bytes());+ dib[8..12].copy_from_slice(&height.to_le_bytes());+ dib[12..14].copy_from_slice(&1u16.to_le_bytes());+ dib[14..16].copy_from_slice(&8u16.to_le_bytes());+ dib[16..20].copy_from_slice(&0u32.to_le_bytes()); // BI_RGB+ dib[32..36].copy_from_slice(&clr_used.to_le_bytes());++ // Color 0: Blue [255, 0, 0, 0]+ dib[40..44].copy_from_slice(&[255, 0, 0, 0]);+ // Color 1: Yellow [0, 255, 255, 0]+ dib[44..48].copy_from_slice(&[0, 255, 255, 0]);++ // Row 0: index 1 (Yellow)+ let row0 = 40 + palette_size;+ dib[row0] = 1;+ dib[row0 + 1] = 1;++ let png = super::decode_dib_to_png(&dib).expect("png");+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();+ assert_eq!(decoded.width(), 2);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(0, 1);+ assert!(p[0] > 240 && p[1] > 240 && p[2] < 10 && p[3] == 255, "yellow pixel: {p:?}");+ }++ static CLIPBOARD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());++ /// Round-trip test: writes 24-bit CF_DIB to native Windows clipboard and verifies decoding.+ #[cfg(target_os = "windows")]+ #[test]+ fn windows_clipboard_dib_round_trip() {+ let _lock = CLIPBOARD_LOCK.lock().unwrap();+ let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok());+ let _restore = Restore(previous);++ let dib = make_cf_dib_24(2, 2, &[0, 0, 255]); // red+ {+ let _clip = clipboard_win::Clipboard::new_attempts(10).expect("open clipboard");+ let _ = clipboard_win::raw::empty();+ clipboard_win::raw::set_without_clear(clipboard_win::formats::CF_DIB, &dib).expect("set CF_DIB");+ }++ let png = super::image_png().expect("read image from windows clipboard");+ let decoded = image::load_from_memory(&png).expect("decode png").to_rgba8();+ assert_eq!(decoded.width(), 2);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(0, 0);+ assert!(p[0] > 200 && p[1] < 40 && p[2] < 40 && p[3] > 200, "pixel should be red: {p:?}");+ }++ /// Round-trip test: writes 32-bit CF_DIBV5 to native Windows clipboard and verifies decoding.+ #[cfg(target_os = "windows")]+ #[test]+ fn windows_clipboard_dibv5_round_trip() {+ let _lock = CLIPBOARD_LOCK.lock().unwrap();+ let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok());+ let _restore = Restore(previous);++ let dib = make_cf_dibv5_32(2, 2, &[0, 255, 0, 180], 0xff000000); // green with alpha 180+ {+ let _clip = clipboard_win::Clipboard::new_attempts(10).expect("open clipboard");+ let _ = clipboard_win::raw::empty();+ clipboard_win::raw::set_without_clear(clipboard_win::formats::CF_DIBV5, &dib).expect("set CF_DIBV5");+ }++ let png = super::image_png().expect("read dibv5 from windows clipboard");+ let decoded = image::load_from_memory(&png).expect("decode png").to_rgba8();+ assert_eq!(decoded.width(), 2);+ assert_eq!(decoded.height(), 2);+ let p = decoded.get_pixel(0, 0);+ assert!(p[0] < 40 && p[1] > 200 && p[2] < 40 && p[3] == 180, "pixel should be green with alpha 180: {p:?}");+ }++ /// Round-trip test: writes 32-bit zero-alpha CF_DIB (Snipping Tool format) and verifies opacity restoration.+ #[cfg(target_os = "windows")]+ #[test]+ fn windows_clipboard_zero_alpha_snipping_tool_round_trip() {+ let _lock = CLIPBOARD_LOCK.lock().unwrap();+ let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok());+ let _restore = Restore(previous);++ let dib = make_cf_dib_32(2, 2, &[255, 0, 0, 0]); // blue with 0 alpha+ {+ let _clip = clipboard_win::Clipboard::new_attempts(10).expect("open clipboard");+ let _ = clipboard_win::raw::empty();+ clipboard_win::raw::set_without_clear(clipboard_win::formats::CF_DIB, &dib).expect("set CF_DIB");+ }++ let png = super::image_png().expect("read image from windows clipboard");+ let decoded = image::load_from_memory(&png).expect("decode png").to_rgba8();+ let p = decoded.get_pixel(0, 0);+ assert!(p[0] < 40 && p[1] < 40 && p[2] > 200 && p[3] == 255, "zero alpha restored to opaque: {p:?}");+ }+ /// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back. /// Restores whatever plain text was there. Other platforms return immediately. #[test]@@ -41,6 +767,7 @@ mod tests { if !cfg!(target_os = "macos") { return; }+ let _lock = CLIPBOARD_LOCK.lock().unwrap(); let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()); let _restore = Restore(previous); assert!(super::set_html("<b>DeckCraft</b>", "DeckCraft"), "set html");@@ -62,6 +789,7 @@ mod tests { struct Restore(Option<String>); impl Drop for Restore {+ /// Restores previous clipboard text on drop. fn drop(&mut self) { let Ok(mut cb) = arboard::Clipboard::new() else { return }; match self.0.take() {@@ -212,6 +212,8 @@ pub struct Services { /// Hand bytes to the user as a named file (browser download). pub download: Option<DownloadFn>, pub inbox: Option<Inbox>,+ /// Plain text on the system clipboard, when the host can read one.+ pub clipboard_text: Option<Box<dyn FnMut() -> Option<String>>>, /// Image on the system clipboard (PNG bytes), when the host can read one. pub clipboard_image: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>, /// HTML on the system clipboard (Pages, Keynote, and other apps that write `public.html`).@@ -654,7 +656,25 @@ impl SlideApp { /// Run a command by id (UI commands here, everything else in the engine). Errors show in the /// status bar and are returned. pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {- let r = if UI_COMMANDS.iter().any(|c| c.0 == id) {+ let r = if id == "edit.paste" && params.as_object().is_none_or(|m| m.is_empty()) {+ let text = self.services.clipboard_text.as_mut().and_then(|f| f()).unwrap_or_default();+ let text = if text == OBJECTS_MARKER { String::new() } else { text };+ if text.is_empty() {+ if let Some(png) = self.services.clipboard_image.as_mut().and_then(|f| f()) {+ let data = deckcraft_engine::cmd::base64_encode(&png);+ self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})).map_err(|e| e.to_string())+ } else {+ self.session.execute(id, ¶ms).map_err(|e| e.to_string())+ }+ } else {+ let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty());+ let mut params = json!({"text": text});+ if let Some(h) = html {+ params["html"] = Value::String(h);+ }+ self.session.execute("edit.paste", ¶ms).map_err(|e| e.to_string())+ }+ } else if UI_COMMANDS.iter().any(|c| c.0 == id) { self.run_ui(id, ¶ms) } else { self.session.execute(id, ¶ms).map_err(|e| e.to_string())@@ -2477,6 +2497,62 @@ mod tests { assert_eq!(pictures(harness.state()), 1); } + /// Paste from the ribbon, menus or context menu pastes the clipboard image when one is available (#53).+ #[test]+ fn menu_paste_inserts_an_image_when_available() {+ let session = Session::with_new();+ let png = {+ let d = session.active().unwrap();+ deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0+ };+ assert!(!png.is_empty());+ let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() };+ let mut app = SlideApp::new(session, services);+ let pictures = |app: &SlideApp| {+ let d = app.session.active().unwrap();+ d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count()+ };+ assert_eq!(pictures(&app), 0);+ app.run("edit.paste", json!({})).unwrap();+ assert_eq!(pictures(&app), 1, "menu paste should insert the clipboard image");+ }++ /// Menu paste prioritizes clipboard text over an available image.+ #[test]+ fn menu_paste_prioritizes_clipboard_text_over_image() {+ let session = Session::with_new();+ let png = {+ let d = session.active().unwrap();+ deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0+ };+ let services = Services {+ clipboard_text: Some(Box::new(|| Some("External note".to_string()))),+ clipboard_image: Some(Box::new(move || Some(png.clone()))),+ ..Default::default()+ };+ let mut app = SlideApp::new(session, services);+ app.run("edit.paste", json!({})).unwrap();+ let d = app.session.active().unwrap();+ let shape = d.doc.slides[0].shapes.last().unwrap();+ assert!(matches!(shape.kind, deckcraft_model::ShapeKind::Shape));+ assert_eq!(shape.text.as_ref().map(|t| t.text()).as_deref(), Some("External note"));+ }++ /// Menu paste with OBJECTS_MARKER falls back to internal shapes on the session clipboard.+ #[test]+ fn menu_paste_with_objects_marker_pastes_internal_shapes() {+ let mut session = Session::with_new();+ session.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();+ session.execute("edit.copy", &json!({})).unwrap();+ let initial_count = session.active().unwrap().doc.slides[0].shapes.len();++ let services = Services { clipboard_text: Some(Box::new(|| Some(OBJECTS_MARKER.to_string()))), ..Default::default() };+ let mut app = SlideApp::new(session, services);+ app.run("edit.paste", json!({})).unwrap();+ let new_count = app.session.active().unwrap().doc.slides[0].shapes.len();+ assert_eq!(new_count, initial_count + 1);+ }+ /// #78: after Tab in a text box, typing and deleting no longer reached the text. #[test] fn typing_goes_on_after_tab_in_a_text_box() {