diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1561,6 +1561,8 @@ dependencies = [
  "fmv-macos-events",
  "image",
  "log",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
  "rfd",
  "serde_json",
  "wat",
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (images placed at their own size, 2x screenshots in points (#1360); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
 
 FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
 native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -348,6 +348,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. Numbers unchanged |
 | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
 | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
 | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml
--- a/apps/figcraft/Cargo.toml
+++ b/apps/figcraft/Cargo.toml
@@ -48,6 +48,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
 # unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
 [target.'cfg(target_os = "macos")'.dependencies]
 fmv-macos-events = "=0.1.0"
+# Paste reads the clipboard's public.png as is, keeping the screen density a screenshot records.
+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSString"] }
 
 [dev-dependencies]
 wat = { workspace = true }
diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs
--- a/apps/figcraft/src/services.rs
+++ b/apps/figcraft/src/services.rs
@@ -327,6 +327,9 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
     Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
 }
 
+#[cfg(target_os = "macos")]
+const MACOS_PASTEBOARD_PNG: &str = "public.png";
+
 /// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
 #[cfg(all(unix, not(target_os = "macos")))]
 const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
@@ -438,6 +441,12 @@ impl Services for Desktop {
         }
         Some((w, h, img.bytes.into_owned()))
     }
+    #[cfg(target_os = "macos")]
+    fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
+        let board = objc2_app_kit::NSPasteboard::generalPasteboard();
+        let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?;
+        Some(png.to_vec())
+    }
     fn open_url(&mut self, url: &str) {
         let _ = webbrowser::open(url);
     }
diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs
--- a/crates/engine/src/cmd/create.rs
+++ b/crates/engine/src/cmd/create.rs
@@ -9,6 +9,8 @@ use super::{CommandSpec, ok};
 use crate::params::*;
 use crate::{Result, Session, bad, spec};
 
+pub const PLACED_IMAGE_MAX_SIDE: f64 = 4096.0;
+
 /// Frame presets shown by the Frame tool (category, name, width, height).
 pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
     ("Phone", "iPhone 16", 393.0, 852.0),
@@ -443,13 +445,12 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
     let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
     let ds = s.ds_mut()?;
     let doc = ds.doc_mut();
+    let density = figcraft_format::pixel_density(&bytes);
     let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
     let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
-    let (iw, ih) = (f64::from(iw), f64::from(ih));
-    // Fit large images into 1000 px like Figma does when placing.
-    let k = (1000.0 / iw.max(ih)).min(1.0);
-    let w = num(p, "width").unwrap_or(iw * k);
-    let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * ih / iw.max(1.0) } else { ih * k });
+    let (placed_w, placed_h) = figcraft_format::placed_size(iw, ih, density, PLACED_IMAGE_MAX_SIDE);
+    let w = num(p, "width").unwrap_or(placed_w);
+    let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * f64::from(ih) / f64::from(iw).max(1.0) } else { placed_h });
     let nid = doc.alloc_id();
     let name = string(p, "name").unwrap_or_else(|| "Image".into());
     let mut node = Node::new(nid, NodeKind::Rectangle, name, w, h);
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -372,6 +372,8 @@ mod pen_overlay_tests;
 #[cfg(test)]
 mod pixel_preview_tests;
 #[cfg(test)]
+mod placed_image_size_tests;
+#[cfg(test)]
 mod playground_tests;
 #[cfg(test)]
 mod plugin_data_tests;
diff --git a/crates/engine/src/placed_image_size_tests.rs b/crates/engine/src/placed_image_size_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/placed_image_size_tests.rs
@@ -0,0 +1,104 @@
+use base64::Engine as _;
+use figcraft_doc::NodeId;
+use serde_json::json;
+
+use crate::Session;
+
+const PIXELS_PER_METER_AT_144_DPI: u32 = 5669;
+const PIXELS_PER_METER_AT_72_DPI: u32 = 2835;
+const PIXELS_PER_METER_AT_300_DPI: u32 = 11811;
+
+fn png(width: u32, height: u32) -> Vec<u8> {
+    figcraft_render::encode_png(&figcraft_render::Pixmap::new(width, height).unwrap()).unwrap()
+}
+
+fn crc32(bytes: &[u8]) -> u32 {
+    let mut crc = !0u32;
+    for &byte in bytes {
+        crc ^= u32::from(byte);
+        for _ in 0..8 {
+            crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
+        }
+    }
+    !crc
+}
+
+fn with_pixels_per_meter(png: &[u8], pixels_per_meter: u32) -> Vec<u8> {
+    let after_header = 8 + 8 + 13 + 4;
+    let mut chunk = b"pHYs".to_vec();
+    chunk.extend(pixels_per_meter.to_be_bytes());
+    chunk.extend(pixels_per_meter.to_be_bytes());
+    chunk.push(1);
+    let mut out = png[..after_header].to_vec();
+    out.extend(9u32.to_be_bytes());
+    out.extend(&chunk);
+    out.extend(crc32(&chunk).to_be_bytes());
+    out.extend(&png[after_header..]);
+    out
+}
+
+fn place(s: &mut Session, bytes: &[u8], extra: serde_json::Value) -> (f64, f64) {
+    let mut params = json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes)});
+    params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
+    let placed = s.execute("image.place", &params).unwrap();
+    let node = s.doc().unwrap().node(NodeId(placed["id"].as_u64().unwrap())).unwrap();
+    (node.width, node.height)
+}
+
+#[test]
+fn an_image_is_placed_at_its_pixel_size() {
+    let mut s = Session::new();
+    assert_eq!(place(&mut s, &png(2400, 1200), json!({})), (2400.0, 1200.0));
+    assert_eq!(place(&mut s, &with_pixels_per_meter(&png(1440, 900), PIXELS_PER_METER_AT_72_DPI), json!({})), (1440.0, 900.0));
+}
+
+#[test]
+fn a_retina_screenshot_is_placed_at_its_point_size() {
+    let mut s = Session::new();
+    let retina = with_pixels_per_meter(&png(800, 600), PIXELS_PER_METER_AT_144_DPI);
+    assert_eq!(figcraft_format::pixel_density(&retina), 2.0);
+    assert_eq!(place(&mut s, &retina, json!({})), (400.0, 300.0));
+}
+
+#[test]
+fn a_print_resolution_is_not_read_as_a_screen_density() {
+    let mut s = Session::new();
+    let scan = with_pixels_per_meter(&png(600, 400), PIXELS_PER_METER_AT_300_DPI);
+    assert_eq!(figcraft_format::pixel_density(&scan), 1.0);
+    assert_eq!(place(&mut s, &scan, json!({})), (600.0, 400.0));
+}
+
+#[test]
+fn a_density_near_a_screen_density_is_not_read_as_one() {
+    for (dpi, pixels_per_meter) in [(141, 5551), (143, 5630), (145, 5709)] {
+        let near = with_pixels_per_meter(&png(4, 4), pixels_per_meter);
+        assert_eq!(figcraft_format::pixel_density(&near), 1.0, "{dpi} dpi");
+    }
+    for (density, pixels_per_meter) in [(2.0, 5670), (3.0, 8504), (4.0, 11339)] {
+        let screen = with_pixels_per_meter(&png(4, 4), pixels_per_meter);
+        assert_eq!(figcraft_format::pixel_density(&screen), density, "{pixels_per_meter} pixels per metre");
+    }
+}
+
+#[test]
+fn only_a_side_past_4096_is_fitted() {
+    let mut s = Session::new();
+    let (w, h) = place(&mut s, &png(8192, 100), json!({}));
+    assert_eq!((w, h), (4096.0, 50.0));
+}
+
+#[test]
+fn an_explicit_size_still_wins() {
+    let mut s = Session::new();
+    let retina = with_pixels_per_meter(&png(800, 600), PIXELS_PER_METER_AT_144_DPI);
+    assert_eq!(place(&mut s, &retina, json!({"width": 200})), (200.0, 150.0));
+    assert_eq!(place(&mut s, &png(2400, 1200), json!({"width": 100, "height": 70})), (100.0, 70.0));
+}
+
+#[test]
+fn bytes_that_are_not_png_have_no_density() {
+    assert_eq!(figcraft_format::pixel_density(b"GIF89a"), 1.0);
+    assert_eq!(figcraft_format::pixel_density(&[]), 1.0);
+    let truncated = &with_pixels_per_meter(&png(4, 4), PIXELS_PER_METER_AT_144_DPI)[..40];
+    assert_eq!(figcraft_format::pixel_density(truncated), 1.0);
+}
diff --git a/crates/format/src/density.rs b/crates/format/src/density.rs
new file mode 100644
--- /dev/null
+++ b/crates/format/src/density.rs
@@ -0,0 +1,41 @@
+const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
+const POINTS_PER_INCH: f64 = 72.0;
+const METERS_PER_INCH: f64 = 0.0254;
+const UNIT_METER: u8 = 1;
+const MAX_DENSITY: f64 = 4.0;
+const DENSITY_TOLERANCE: f64 = 0.005;
+
+pub fn pixel_density(bytes: &[u8]) -> f64 {
+    png_pixels_per_meter(bytes).map_or(1.0, density_from_pixels_per_meter)
+}
+
+pub fn placed_size(pixel_width: u32, pixel_height: u32, density: f64, max_side: f64) -> (f64, f64) {
+    let (w, h) = (f64::from(pixel_width) / density, f64::from(pixel_height) / density);
+    let fit = (max_side / w.max(h)).min(1.0);
+    (w * fit, h * fit)
+}
+
+fn density_from_pixels_per_meter(pixels_per_meter: u32) -> f64 {
+    let ratio = f64::from(pixels_per_meter) * METERS_PER_INCH / POINTS_PER_INCH;
+    let whole = ratio.round();
+    if (2.0..=MAX_DENSITY).contains(&whole) && (ratio - whole).abs() < DENSITY_TOLERANCE { whole } else { 1.0 }
+}
+
+fn png_pixels_per_meter(bytes: &[u8]) -> Option<u32> {
+    let mut rest = bytes.strip_prefix(PNG_SIGNATURE)?;
+    while rest.len() >= 12 {
+        let length = usize::try_from(u32::from_be_bytes(rest[..4].try_into().ok()?)).ok()?;
+        let kind = &rest[4..8];
+        let data = rest.get(8..8usize.checked_add(length)?)?;
+        match kind {
+            b"pHYs" if data.len() == 9 => {
+                let x = u32::from_be_bytes(data[..4].try_into().ok()?);
+                let y = u32::from_be_bytes(data[4..8].try_into().ok()?);
+                return (data[8] == UNIT_METER && x == y).then_some(x);
+            }
+            b"IDAT" | b"IEND" => return None,
+            _ => rest = rest.get(12usize.checked_add(length)?..)?,
+        }
+    }
+    None
+}
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -23,8 +23,11 @@ pub mod rest;
 pub mod sketch;
 pub mod thumbnail;
 
+pub use density::{pixel_density, placed_size};
 pub use library::{is_valid_library_id, read_library, read_library_within};
 
+mod density;
+
 use std::collections::{BTreeMap, BTreeSet};
 use std::io::{Read, Seek, Write};
 use std::sync::Arc;
diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs
--- a/crates/ui-egui/src/menus/clipboard.rs
+++ b/crates/ui-egui/src/menus/clipboard.rs
@@ -62,7 +62,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>)
                 return;
             }
             // Centred in the view; Paste here puts its top-left at the point, as it does layers.
-            let (x, y) = if at.is_some() { (cx, cy) } else { ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()) };
+            let (x, y) = if at.is_some() { (cx, cy) } else { ((cx - w / 2.0).round(), (cy - h / 2.0).round()) };
             app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y}));
             return;
         }
@@ -100,12 +100,32 @@ fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool)
 }
 
 /// The system clipboard's image as PNG bytes, with its size.
-fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, u32, u32)> {
+fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
+    let (png, (w, h)) = match app.services.clipboard_get_png().and_then(with_pixel_size) {
+        Some(readable) => readable,
+        None => with_pixel_size(encoded_clipboard_image(app)?)?,
+    };
+    if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
+        return None;
+    }
+    let density = figcraft_format::pixel_density(&png);
+    let (w, h) = figcraft_format::placed_size(w, h, density, figcraft_engine::cmd::create::PLACED_IMAGE_MAX_SIDE);
+    Some((png, w, h))
+}
+
+const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000;
+
+fn with_pixel_size(png: Vec<u8>) -> Option<(Vec<u8>, (u32, u32))> {
+    let size = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_dimensions().ok()?;
+    Some((png, size))
+}
+
+fn encoded_clipboard_image(app: &mut FigApp) -> Option<Vec<u8>> {
     let (w, h, rgba) = app.services.clipboard_get_image()?;
     let img = image::RgbaImage::from_raw(w, h, rgba)?;
     let mut out = std::io::Cursor::new(Vec::new());
     img.write_to(&mut out, image::ImageFormat::Png).ok()?;
-    Some((out.into_inner(), w, h))
+    Some(out.into_inner())
 }
 
 /// The text of a selection made only of text layers, one layer per line (None for any other
diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs
--- a/crates/ui-egui/src/paste_image_tests.rs
+++ b/crates/ui-egui/src/paste_image_tests.rs
@@ -29,6 +29,7 @@ const HELD: [Held; 3] = [Held::Ours, Held::Text, Held::Image];
 struct Clip {
     text: Option<String>,
     image: Option<(u32, u32, Vec<u8>)>,
+    png: Option<Vec<u8>>,
 }
 
 /// The desktop clipboard.
@@ -36,7 +37,7 @@ struct Desktop(Rc<RefCell<Clip>>);
 
 impl Services for Desktop {
     fn clipboard_set_text(&mut self, s: &str) {
-        *self.0.borrow_mut() = Clip { text: Some(s.into()), image: None };
+        *self.0.borrow_mut() = Clip { text: Some(s.into()), ..Clip::default() };
     }
     fn clipboard_get_text(&mut self) -> Option<String> {
         self.0.borrow().text.clone()
@@ -44,6 +45,9 @@ impl Services for Desktop {
     fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
         self.0.borrow().image.clone()
     }
+    fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
+        self.0.borrow().png.clone()
+    }
 }
 
 struct Win {
@@ -90,7 +94,7 @@ impl Win {
     }
 
     fn copy_image_elsewhere(&mut self) {
-        *self.clip.borrow_mut() = Clip { text: None, image: Some((2, 2, vec![255; 16])) };
+        *self.clip.borrow_mut() = Clip { image: Some((2, 2, vec![255; 16])), ..Clip::default() };
     }
 
     /// Cmd/Ctrl+V as egui-winit delivers it: the press brings a paste event when the clipboard
@@ -262,7 +266,7 @@ fn cmd_released_before_v_still_pastes_the_image() {
 #[test]
 fn text_pastes_once_from_its_paste_event() {
     let mut w = Win::new();
-    *w.clip.borrow_mut() = Clip { text: Some("hello from another app".into()), image: None };
+    *w.clip.borrow_mut() = Clip { text: Some("hello from another app".into()), ..Clip::default() };
     w.cmd_v(CMD);
     assert_eq!(w.texts(), 1, "the paste event pasted the text, the release nothing more");
     assert_eq!(w.rects(), 1);
@@ -271,7 +275,7 @@ fn text_pastes_once_from_its_paste_event() {
 #[test]
 fn text_pastes_once_when_another_key_is_let_go_first() {
     let mut w = Win::new();
-    *w.clip.borrow_mut() = Clip { text: Some("hello".into()), image: None };
+    *w.clip.borrow_mut() = Clip { text: Some("hello".into()), ..Clip::default() };
     // Cmd+A then Cmd+V typed fast: A comes up after V went down.
     w.frame(vec![key(Key::A, true, CMD)]);
     w.frame(vec![Event::Paste("hello".into())]);
@@ -314,7 +318,7 @@ fn v_alone_pastes_nothing() {
 #[test]
 fn a_paste_event_from_another_key_does_not_block_the_next_cmd_v() {
     let mut w = Win::new();
-    *w.clip.borrow_mut() = Clip { text: Some("text".into()), image: None };
+    *w.clip.borrow_mut() = Clip { text: Some("text".into()), ..Clip::default() };
     // Shift+Insert on Windows.
     w.frame(vec![Event::Paste("text".into())]);
     w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT)]);
@@ -479,3 +483,54 @@ fn cmd_v_after_a_paste_chord_is_a_plain_paste_again() {
     assert_eq!(w.texts(), 1, "the text was pasted by the plain Cmd+V");
     assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "Shift+Cmd+V pasted over once");
 }
+
+fn crc32(bytes: &[u8]) -> u32 {
+    let mut crc = !0u32;
+    for &byte in bytes {
+        crc ^= u32::from(byte);
+        for _ in 0..8 {
+            crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
+        }
+    }
+    !crc
+}
+
+fn retina_screenshot(width: u32, height: u32) -> Vec<u8> {
+    let mut png = std::io::Cursor::new(Vec::new());
+    image::RgbaImage::new(width, height).write_to(&mut png, image::ImageFormat::Png).unwrap();
+    let png = png.into_inner();
+    let after_header = 8 + 8 + 13 + 4;
+    let mut chunk = b"pHYs".to_vec();
+    chunk.extend(5669u32.to_be_bytes());
+    chunk.extend(5669u32.to_be_bytes());
+    chunk.push(1);
+    let mut out = png[..after_header].to_vec();
+    out.extend(9u32.to_be_bytes());
+    out.extend(&chunk);
+    out.extend(crc32(&chunk).to_be_bytes());
+    out.extend(&png[after_header..]);
+    out
+}
+
+#[test]
+fn a_retina_screenshot_on_the_clipboard_pastes_at_its_point_size_centred() {
+    let mut w = Win::new();
+    *w.clip.borrow_mut() = Clip { png: Some(retina_screenshot(800, 600)), image: Some((2, 2, vec![255; 16])), ..Clip::default() };
+    w.cmd_v(CMD);
+    let doc = w.app.session.doc().unwrap();
+    let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted");
+    assert_eq!((pasted.width, pasted.height), (400.0, 300.0), "points, not the 800 x 600 pixels");
+    let view = &w.app.session.ds().unwrap().view;
+    let at = doc.world_bounds(pasted.id).unwrap();
+    assert_eq!((at.x0, at.y0), ((view.cx - 200.0).round(), (view.cy - 150.0).round()));
+}
+
+#[test]
+fn an_unreadable_png_on_the_clipboard_falls_back_to_the_decoded_image() {
+    let mut w = Win::new();
+    *w.clip.borrow_mut() = Clip { png: Some(b"\x89PNG\r\n\x1a\nnot an image".to_vec()), image: Some((2, 2, vec![255; 16])), ..Clip::default() };
+    w.cmd_v(CMD);
+    let doc = w.app.session.doc().unwrap();
+    let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
+    assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
+}
diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs
--- a/crates/ui-egui/src/services.rs
+++ b/crates/ui-egui/src/services.rs
@@ -40,6 +40,9 @@ pub trait Services {
     fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
         None
     }
+    fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
+        None
+    }
     fn open_url(&mut self, _url: &str) {}
     /// Show the file at `path` in the platform's file manager (Finder, Explorer), selected where the
     /// platform can.
