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.
This commit is contained in:
Denis Gomes
2026-10-27 01:38:19 -04:00
parent 43af145a12
commit a497b99078
11 changed files with 229 additions and 13 deletions
Generated
+2
View File
@@ -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",
+2
View File
@@ -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 }
+9
View File
@@ -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);
}
+6 -5
View File
@@ -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);
+2
View File
@@ -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;
@@ -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<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 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);
}
+41
View File
@@ -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<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
}
+3
View File
@@ -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;
+19 -3
View File
@@ -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<u8>, u32, u32)> {
fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, 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<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
+50 -5
View File
@@ -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,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<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()));
}
+3
View File
@@ -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.