diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -895,6 +895,7 @@ name = "deckcraft"
 version = "0.8.0"
 dependencies = [
  "arboard",
+ "clipboard-win",
  "cpal",
  "deckcraft-engine",
  "deckcraft-media",
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml
--- a/apps/deckcraft/Cargo.toml
+++ b/apps/deckcraft/Cargo.toml
@@ -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 }
diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs
--- a/apps/deckcraft/src/main.rs
+++ b/apps/deckcraft/src/main.rs
@@ -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)),
diff --git a/apps/deckcraft/src/pasteboard.rs b/apps/deckcraft/src/pasteboard.rs
--- a/apps/deckcraft/src/pasteboard.rs
+++ b/apps/deckcraft/src/pasteboard.rs
@@ -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() {
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -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, &params).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", &params).map_err(|e| e.to_string())
+            }
+        } else if UI_COMMANDS.iter().any(|c| c.0 == id) {
             self.run_ui(id, &params)
         } else {
             self.session.execute(id, &params).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() {
