From a497b99078e5cef8b3c3781cecc0959111d78caa Mon Sep 17 00:00:00 2001 From: Denis Gomes <6731020+denisg0mes@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:57:39 -0400 Subject: [PATCH] M7: place images at their own size, in points for 2x screenshots A placed image was always fitted into 1000 px on its longer side, so a screenshot of a 1440 px wide page came in at 1000 px and stopped lining up with the design it was taken from. Figma places an image at its pixel size and only scales down a side past 4096 px. Placing now does the same, and reads the screen density a PNG records: macOS screenshots carry 144 dpi (pHYs), so an 800 x 600 px capture of a 400 x 300 pt area comes in at 400 x 300, as in Figma. Only whole 2x-4x multiples of 72 dpi count as a screen density; 300 dpi print scans and other values keep their pixel size. The image data stays at full resolution, so exports at 2x and up stay sharp. On macOS, pasting reads the clipboard's public.png as is instead of the decoded pixels arboard returns, which drop the density (and the colour profile). Other platforms keep the decoded image. Files dropped, Place image, paste and image.place all go through the same path; an explicit width/height still wins. --- Cargo.lock | 2 + apps/figcraft/Cargo.toml | 2 + apps/figcraft/src/services.rs | 9 ++ crates/engine/src/cmd/create.rs | 11 +-- crates/engine/src/lib.rs | 2 + crates/engine/src/placed_image_size_tests.rs | 92 ++++++++++++++++++++ crates/format/src/density.rs | 41 +++++++++ crates/format/src/lib.rs | 3 + crates/ui-egui/src/menus/clipboard.rs | 22 ++++- crates/ui-egui/src/paste_image_tests.rs | 55 ++++++++++-- crates/ui-egui/src/services.rs | 3 + 11 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/placed_image_size_tests.rs create mode 100644 crates/format/src/density.rs diff --git a/Cargo.lock b/Cargo.lock index 413011ab..98436f8a 100644 --- 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/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bd8c09c8..daeeacb8 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -48,6 +48,8 @@ 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" +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 index b83a2664..44bb20c0 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -327,6 +327,9 @@ fn picked(pick: impl Future> + '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> { + 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 index 414a2310..ef96da1b 100644 --- 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 { 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 index 60f240cb..a0eaf02e 100644 --- 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 index 00000000..72cf2581 --- /dev/null +++ b/crates/engine/src/placed_image_size_tests.rs @@ -0,0 +1,92 @@ +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 { + 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 { + 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", ¶ms).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 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 index 00000000..3b6b97f5 --- /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.05; + +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 { + 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 index 30f6e548..622a2498 100644 --- 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 index dd775297..9d776f7a 100644 --- 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,28 @@ 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, u32, u32)> { +fn clipboard_png(app: &mut FigApp) -> Option<(Vec, f64, f64)> { + let png = match app.services.clipboard_get_png() { + Some(png) => png, + None => encoded_clipboard_image(app)?, + }; + let (w, h) = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_dimensions().ok()?; + 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 encoded_clipboard_image(app: &mut FigApp) -> Option> { 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 index a76eae79..98313ee6 100644 --- 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, image: Option<(u32, u32, Vec)>, + png: Option>, } /// The desktop clipboard. @@ -36,7 +37,7 @@ struct Desktop(Rc>); 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 { self.0.borrow().text.clone() @@ -44,6 +45,9 @@ impl Services for Desktop { fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { self.0.borrow().image.clone() } + fn clipboard_get_png(&mut self) -> Option> { + 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,44 @@ 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 { + 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())); +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d7490de4..80a25e48 100644 --- 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)> { None } + fn clipboard_get_png(&mut self) -> Option> { + 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.