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.