diff --git a/ROADMAP.md b/ROADMAP.md index 74a617f3..e0e3ed01 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-28 · **Change:** minor (`.fig` embedded images copied once per blob (#1059); 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 @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · 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** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. @@ -348,6 +349,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. 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/crates/format/src/fig.rs b/crates/format/src/fig.rs index d81a5856..c7f4a382 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -47,6 +47,7 @@ mod text_style_tests; use std::collections::BTreeMap; use std::io::Read; +use std::sync::Arc; use figcraft_doc::Document; @@ -109,9 +110,9 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes); // The message goes before the layers are built, so the images it carries are copied out first. - let embedded: BTreeMap> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect(); + let embedded = embedded_copies(&msg); drop(msg); - let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); + let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec())); let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; // Both stages stop at the same cap and say so in the same words: one note (#981). for w in more.into_iter().filter_map(map::adapt_warning) { @@ -151,17 +152,17 @@ fn corrupt(msg: impl Into) -> FormatError { /// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's /// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed /// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The -/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The -/// bytes stay borrowed from the message. -fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap { +/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. +/// Returns the index of each hash's blob. +fn embedded_images(msg: &Kv) -> BTreeMap { let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); let mut found = BTreeMap::new(); let mut pending = vec![msg]; while let Some(value) = pending.pop() { match value { Kv::Object(fields) => { - if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) { - found.entry(hash).or_insert(bytes); + if let Some((hash, index)) = value.get("image").and_then(|image| embedded_image(image, blobs)) { + found.entry(hash).or_insert(index); } pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field)); } @@ -172,15 +173,35 @@ fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap { found } -/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob. -fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> { +/// The images that travel inside the message ([`embedded_images`]), copied out of it once per +/// blob: every hash naming the same blob shares its copy. A file can point any number of hashes +/// at one large blob, and a copy for each would multiply it (#1059). +fn embedded_copies(msg: &Kv) -> BTreeMap> { + let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); + let mut copies: BTreeMap> = BTreeMap::new(); + let mut out = BTreeMap::new(); + for (hash, index) in embedded_images(msg) { + let Some(bytes) = blob(blobs, index) else { continue }; + out.insert(hash, Arc::clone(copies.entry(index).or_insert_with(|| Arc::from(bytes)))); + } + out +} + +/// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob. +fn embedded_image(image: &Kv, blobs: &[Kv]) -> Option<(String, usize)> { let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?; let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None }; - let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?; + let index = usize::try_from(*index).ok()?; + let bytes = blob(blobs, index)?; if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE { return None; } - Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes)) + Some((hash.iter().map(|b| format!("{b:02x}")).collect(), index)) +} + +/// The bytes of blob `index`. +fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> { + blobs.get(index)?.get("bytes").and_then(Kv::bytes) } /// The parts of a `.fig` archive the import reads. diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 4ebb117f..c1253048 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -777,6 +777,23 @@ fn a_data_blob_past_the_blobs_stays_a_placeholder() { assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); } +/// A file can point any number of image hashes at one large blob; each used to get its own copy +/// of it (#1059). +#[test] +fn hashes_naming_one_blob_share_one_copy_of_it() { + let image = |hash: u8, blob: u64| o(vec![("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]); + let msg = o(vec![ + ("blobs", Kv::Array(vec![o(vec![("bytes", Kv::Bytes(png()))]), o(vec![("bytes", Kv::Bytes(vec![7; 16]))])])), + ("nodeChanges", Kv::Array((1..=50).map(|h| image(h, 0)).chain([image(99, 1), image(98, 2)]).collect())), + ]); + let copies = embedded_copies(&msg); + assert_eq!(copies.len(), 51, "the hash naming no blob is left out"); + let first = &copies[&"01".repeat(20)]; + assert_eq!(**first, *png()); + assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50); + assert_eq!(*copies[&"63".repeat(20)], [7; 16]); +} + #[test] fn other_files_are_not_fig() { assert!(try_import(b"{\"document\": {}}", "x").is_none());