diff --git a/ROADMAP.md b/ROADMAP.md index 74a617f3..a54f7739 100644 --- 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 index daeeacb8..824bb0d6 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -48,6 +48,7 @@ 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"] } diff --git a/crates/engine/src/placed_image_size_tests.rs b/crates/engine/src/placed_image_size_tests.rs index 72cf2581..65c61f7a 100644 --- a/crates/engine/src/placed_image_size_tests.rs +++ b/crates/engine/src/placed_image_size_tests.rs @@ -68,6 +68,18 @@ fn a_print_resolution_is_not_read_as_a_screen_density() { 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(); diff --git a/crates/format/src/density.rs b/crates/format/src/density.rs index 3b6b97f5..9f9f67c8 100644 --- a/crates/format/src/density.rs +++ b/crates/format/src/density.rs @@ -3,7 +3,7 @@ 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; +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) diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index 9d776f7a..5185e4a8 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -101,11 +101,10 @@ 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, f64, f64)> { - let png = match app.services.clipboard_get_png() { - Some(png) => png, - None => encoded_clipboard_image(app)?, + 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)?)?, }; - 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; } @@ -116,6 +115,11 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, f64, f64)> { const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000; +fn with_pixel_size(png: Vec) -> Option<(Vec, (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> { let (w, h, rgba) = app.services.clipboard_get_image()?; let img = image::RgbaImage::from_raw(w, h, rgba)?; diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 98313ee6..821e1ec8 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -524,3 +524,13 @@ fn a_retina_screenshot_on_the_clipboard_pastes_at_its_point_size_centred() { 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)); +}