mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
M6: .fig embedded images are copied once per blob, not once per hash (#1059)
Images carried inside a `.fig` message (`image.dataBlob` indexing the message's `blobs`) were copied out before the message is dropped, one copy per image hash. Nothing stops a file from pointing many hashes at one blob: 100 hashes on a 250 MB blob made 25 GB of copies, an out-of-memory abort from a small file. The walk now records each hash's blob index, and `embedded_copies` makes one shared copy per blob for every hash that names it, so the copies are bounded by the message's own size. Each resolved image is still copied once on its way into the document, where images are kept by content hash. Regression: fifty hashes on one blob share one copy; a hash naming a missing blob is left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2af5e232d5
commit
3009d19fde
+3
-1
@@ -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 |
|
||||
|
||||
+32
-11
@@ -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<String>), 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<String, Vec<u8>> = 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<String>) -> 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<String, &'m [u8]> {
|
||||
/// 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<String, usize> {
|
||||
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<String, &'m [u8]> {
|
||||
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<String, Arc<[u8]>> {
|
||||
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
|
||||
let mut copies: BTreeMap<usize, Arc<[u8]>> = 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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user