From 1cd9c0d87583de1903fffe57acd6dceb61deefae Mon Sep 17 00:00:00 2001 From: Guillermo Bueno Date: Sun, 25 Oct 2026 14:36:07 +0100 Subject: [PATCH 01/30] M6: add bounded offline Penpot import and export --- Cargo.lock | 1 + ROADMAP.md | 5 +- apps/figcraft-cli/src/files.rs | 29 +- apps/figcraft-cli/src/main.rs | 7 +- apps/figcraft-cli/tests/it/cli.rs | 34 + apps/figcraft/src/startup_tests.rs | 21 +- crates/engine/src/cmd/file.rs | 423 +- crates/engine/src/export_behaviour_tests.rs | 17 + crates/engine/src/penpot_export_tests.rs | 152 + crates/format/Cargo.toml | 1 + crates/format/src/lib.rs | 1 + crates/format/src/penpot.rs | 6481 +++++++++++++++++++ crates/format/src/penpot/embedded_fonts.rs | 57 + crates/format/src/penpot/export.rs | 1805 ++++++ crates/format/src/penpot/export_tests.rs | 889 +++ crates/format/src/penpot/prototype.rs | 346 + crates/pathops/src/lib.rs | 53 +- crates/pathops/src/width.rs | 2 +- crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/lib.rs | 8 +- docs/PENPOT_IMPORT.md | 107 + docs/cli.md | 12 +- docs/file-format-parity.md | 4 +- 23 files changed, 10420 insertions(+), 36 deletions(-) create mode 100644 crates/engine/src/penpot_export_tests.rs create mode 100644 crates/format/src/penpot.rs create mode 100644 crates/format/src/penpot/embedded_fonts.rs create mode 100644 crates/format/src/penpot/export.rs create mode 100644 crates/format/src/penpot/export_tests.rs create mode 100644 crates/format/src/penpot/prototype.rs create mode 100644 docs/PENPOT_IMPORT.md diff --git a/Cargo.lock b/Cargo.lock index b8c6127ba..dd3e11b50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1596,6 +1596,7 @@ name = "figcraft-format" version = "0.11.1" dependencies = [ "figcraft-doc", + "figcraft-layout", "figcraft-pathops", "flate2", "image", diff --git a/ROADMAP.md b/ROADMAP.md index 69d3bf731..7c685a420 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-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; 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-25 · **Change:** minor (M6: offline Penpot import/export using the existing document model, bounded archive and token parsing) · **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,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-25** · M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. + - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -329,6 +331,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3d36a7f28..e22c16f0a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -11,7 +11,13 @@ use serde_json::{Value, json}; use crate::{CliError, CliResult}; pub fn load(path: &str) -> CliResult { - load_noted(path).map(|(d, _)| d) + let (doc, notes) = load_noted(path)?; + if ext(path) == "penpot" { + for note in notes { + eprintln!("note: {note}"); + } + } + Ok(doc) } /// The document with the loader's notes (repairs of a damaged file, or import warnings). @@ -120,7 +126,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> { "png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?, "pdf" => page_pdf(doc, page, &PrintSettings::default())?, "fig" => fig_bytes(doc)?, - other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))), + "penpot" => penpot_bytes(doc)?, + other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig, penpot)"))), }; write(out, &bytes) } @@ -134,6 +141,14 @@ fn fig_bytes(doc: &Document) -> CliResult> { Ok(bytes) } +fn penpot_bytes(doc: &Document) -> CliResult> { + let (bytes, warnings) = figcraft_engine::cmd::file::penpot_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?; + for w in warnings { + eprintln!("note: {w}"); + } + Ok(bytes) +} + /// A page as PDF: its top-level frames one page each, or (without frames) everything on one page. fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult> { let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) { @@ -235,12 +250,16 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { println!("{out}"); return Ok(ExitCode::SUCCESS); } + if t.format.as_deref().map_or(ext(out) == "penpot", |f| f.eq_ignore_ascii_case("penpot")) { + write(out, &penpot_bytes(&doc)?)?; + println!("{out}"); + return Ok(ExitCode::SUCCESS); + } let page = page_named(&doc, t.page.as_deref())?; let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print); let fmt = match t.format.as_deref() { - Some(f) => { - image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))? - } + Some(f) => image_format(&f.to_ascii_lowercase()) + .ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig|penpot)")))?, None => image_format(&ext(out)).unwrap_or(ExportFormat::Png), }; let target = node.map(|q| find_node(&doc, page, q)).transpose()?; diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 666276731..ef9e244b0 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -17,7 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window USAGE: figcraft-cli info [--json] - figcraft-cli export -o [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] + figcraft-cli export -o [--format png|jpg|svg|webp|pdf|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] @@ -33,11 +33,12 @@ Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): te fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are indexed the first time text is laid out, as in the app. -Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what +Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file per top-level frame into the directory, or one file when has an image extension or --node is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may -not open every file); PDFs take print options (a bleed like 3mm or 0.125in, +not open every file; `penpot`, or an ending in .penpot: the whole file as a Penpot 2.x +archive to import into a Penpot team); PDFs take print options (a bleed like 3mm or 0.125in, crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of {\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step. `plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) diff --git a/apps/figcraft-cli/tests/it/cli.rs b/apps/figcraft-cli/tests/it/cli.rs index b1aeb54a9..c51cba3af 100644 --- a/apps/figcraft-cli/tests/it/cli.rs +++ b/apps/figcraft-cli/tests/it/cli.rs @@ -13,6 +13,40 @@ fn tmp(name: &str) -> std::path::PathBuf { d } +/// Conversion and image commands must report the Penpot loader's notes too. +#[test] +fn penpot_import_notes_reach_convert_export_and_render() { + use figcraft_doc::proto::{Action, Easing, Interaction, Transition, Trigger}; + use figcraft_doc::{Document, NodeKind}; + let d = tmp("penpot-notes"); + let mut doc = Document::new("Prototype"); + let page = doc.first_page().unwrap(); + let home = doc.create(page, NodeKind::Frame, "Home", 0.0, 0.0, 40.0, 40.0).unwrap(); + let dest = doc.create(page, NodeKind::Frame, "Next", 60.0, 0.0, 40.0, 40.0).unwrap(); + doc.node_mut(home).unwrap().interactions.push(Interaction { + trigger: Trigger::OnClick, + actions: vec![Action::Navigate { dest, preserve_scroll: false }], + transition: Transition::Instant, + duration_ms: 0, + easing: Easing::Linear, + delay_ms: 0, + }); + let input = d.join("source.penpot"); + std::fs::write(&input, figcraft_format::penpot::export(&doc).unwrap().0).unwrap(); + for (command, name) in [("convert", "out.figcraft"), ("export", "out.svg"), ("render", "out.png")] { + let mut cmd = bin(); + cmd.arg(command).arg(&input); + if command != "convert" { + cmd.arg("-o"); + } + let out = cmd.arg(d.join(name)).output().unwrap(); + let notes = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{command}: {notes}"); + assert!(notes.contains("note: Imported 1 Penpot prototype connection"), "{command}: {notes}"); + } + let _ = std::fs::remove_dir_all(d); +} + #[test] fn bad_arguments_exit_2_without_panicking() { for args in [ diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs index 0c92bd19b..68226c599 100644 --- a/apps/figcraft/src/startup_tests.rs +++ b/apps/figcraft/src/startup_tests.rs @@ -4,6 +4,19 @@ use figcraft_ui_egui::dialogs::window_failed as message; const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; +const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose."; +const DRIVER_HINT: &str = if cfg!(windows) { + "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" +} else { + "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." +}; + +/// Other startup tests can change the process-wide language while this message is built. +fn contains_hint(text: &str, key: &str) -> bool { + use figcraft_ui_egui::i18n::{Text, languages}; + text.contains(key) + || languages().iter().flat_map(|lang| lang.ui).any(|(k, value)| *k == key && matches!(value, Text::Plain(hint) if text.contains(hint))) +} #[test] fn the_message_carries_the_error_and_where_to_report_it() { @@ -15,9 +28,9 @@ fn the_message_carries_the_error_and_where_to_report_it() { #[test] fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { let text = message(ERROR, false); - assert!(text.contains("graphics driver"), "{text}"); + assert!(contains_hint(&text, DRIVER_HINT), "{text}"); assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}"); - assert!(!text.contains("unsetting"), "{text}"); + assert!(!contains_hint(&text, BACKEND_HINT), "{text}"); } #[cfg(windows)] @@ -30,6 +43,6 @@ fn on_windows_it_shows_how_to_start_with_opengl() { #[test] fn with_wgpu_backend_set_it_suggests_unsetting_it() { let text = message(ERROR, true); - assert!(text.contains("unsetting"), "{text}"); - assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}"); + assert!(contains_hint(&text, BACKEND_HINT), "{text}"); + assert!(!contains_hint(&text, DRIVER_HINT), "the chosen backend is the likelier cause: {text}"); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4bafa56d5..9bbb03313 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1,5 +1,7 @@ //! File menu: documents, save/open, import/export, versions. +use std::collections::{BTreeMap, BTreeSet}; + use std::sync::Arc; use base64::Engine as _; @@ -42,7 +44,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .penpot, .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", false, open ), @@ -53,6 +55,13 @@ pub fn specs() -> Vec { false, open_recent ), + spec!( + "file.fontReport", + "Inspect document fonts", + "{} list fonts used by text layers, which families are available, and sample layers; does not substitute or change the original family names", + false, + |s, _| Ok(font_report(s.doc()?)) + ), spec!( "file.save", "Save", @@ -70,7 +79,7 @@ pub fn specs() -> Vec { spec!( "file.export", "Export", - "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}", + "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x .penpot archive, with `warnings` for what Penpot cannot represent), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}", false, export ), @@ -98,7 +107,7 @@ pub fn specs() -> Vec { spec!( "file.import", "Import", - "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", + "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, .penpot, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", true, import ), @@ -536,6 +545,19 @@ fn load_with( .notes; Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false)) } + "penpot" => { + let (mut doc, mut notes) = figcraft_format::penpot::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + expand_penpot_raw_svgs(&mut doc, &mut notes); + // Penpot imports stay offline: referenced fonts must be installed locally. + // The format reader stays platform-agnostic. Font availability is checked here, + // where FigCraft's system/embedded font registry is accessible. Never replace + // the original family in the document: it can resolve after installation. + figcraft_text::scan_system_fonts(); + let families = figcraft_text::family_names(); + let mut font_notes = missing_penpot_font_warnings(&doc, &families); + font_notes.append(&mut notes); // Show missing-font details first in the UI toast. + Ok((figcraft_format::Loaded { issues: font_notes, ..doc.into() }, true)) + } "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => { let mut doc = Document::new(file_stem(name_hint)); let page = doc.first_page().ok_or(EngineError::NoDocument)?; @@ -572,6 +594,249 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option) { + let layers = doc + .nodes() + .filter_map(|node| { + let xml = node.plugin_data.get("penpot.svgRawXml")?; + (node.plugin_data.get("penpot.svgRawStatus").map(String::as_str) == Some("pending")) + .then(|| (node.id, node.name.clone(), node.width, node.height, xml.clone(), node.children.is_empty())) + }) + .collect::>(); + let mut ok_count = 0usize; + for (id, name, width, height, xml, empty) in layers { + if !empty { + notes.push(format!("Penpot SVG '{name}': has separate child layers; the original tree was preserved to prevent duplicate rendering")); + continue; + } + match figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name) { + Ok(imported) => { + // An SVG can be syntactically valid yet contain no supported drawable + // elements. Do not claim success or leave an empty extra frame. + if doc.node(imported.frame).is_none_or(|n| n.children.is_empty()) { + let _ = doc.remove(imported.frame); + if let Some(n) = doc.node_mut(id) { + n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into()); + } + if notes.len() < 100 { + notes.push(format!("Penpot SVG '{name}' has no supported drawable elements; the original markup is preserved")); + } + continue; + } + let size = doc.node(imported.frame).map(|n| (n.width, n.height)); + if let Some((svg_w, svg_h)) = size + && svg_w.is_finite() + && svg_h.is_finite() + && svg_w > 0.0 + && svg_h > 0.0 + { + let sx = (width / svg_w).clamp(0.0001, 10_000.0); + let sy = (height / svg_h).clamp(0.0001, 10_000.0); + if let Some(n) = doc.node_mut(imported.frame) { + n.transform = figcraft_geom::Affine::scale_non_uniform(sx, sy); + } + } + if let Some(n) = doc.node_mut(id) { + n.plugin_data.insert("penpot.svgRawStatus".into(), "expanded".into()); + } + for issue in imported.notes { + if notes.len() < 100 { + notes.push(format!("Penpot SVG '{name}': {issue}")); + } + } + ok_count += 1; + } + Err(err) => { + if let Some(n) = doc.node_mut(id) { + n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into()); + } + if notes.len() < 100 { + notes.push(format!("Penpot SVG '{name}' could not be imported: {err}; its original markup is preserved")); + } + } + } + } + if ok_count > 0 && notes.len() < 100 { + notes.push(format!("Penpot: {ok_count} raw SVG layer(s) converted to editable SVG content")); + } +} + +/// Non-destructive font diagnostic for the action palette, CLI and control API. +/// Includes styled runs, not just the default typeface of each text layer. +fn font_report(doc: &Document) -> Value { + figcraft_text::scan_system_fonts(); + font_report_with_families(doc, &figcraft_text::family_names()) +} + +fn font_report_with_families(doc: &Document, installed: &[String]) -> Value { + let known: BTreeSet = installed.iter().map(|s| s.trim().to_lowercase()).collect(); + let mut families: BTreeMap> = BTreeMap::new(); + for node in doc.nodes() { + let NodeKind::Text(text) = &node.kind else { continue }; + if text.characters.is_empty() { + continue; + } + let mut covered = vec![false; text.characters.chars().count().min(100_000)]; + let mut used = BTreeSet::new(); + for run in &text.runs { + let start = run.start.min(covered.len()); + let end = run.end.min(covered.len()); + if start < end { + if !run.style.font_family.trim().is_empty() { + used.insert(run.style.font_family.clone()); + } + for entry in covered.iter_mut().take(end).skip(start) { + *entry = true; + } + } + } + if covered.iter().any(|entry| !entry) { + used.insert(text.style.font_family.clone()); + } + for family in used { + let family = family.trim(); + if family.is_empty() { + continue; + } + if families.len() >= 256 && !families.contains_key(family) { + continue; + } + let layer_name = node.name.chars().filter(|c| !c.is_control()).take(120).collect::(); + let layers = families.entry(family.chars().filter(|c| !c.is_control()).take(160).collect()).or_default(); + if layers.len() < 25 { + layers.insert(layer_name); + } + } + } + // Explain WHERE a Penpot family comes from. `custom-*` references are + // stored in Penpot's font service, not necessarily in its design archive. + // Keep the id for diagnostics; never treat it as a download URL. + let mut external: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + for node in doc.nodes() { + for key in ["penpot.googleFontRefs", "penpot.customFontRefs", "penpot.customLibraryFontRefs"] { + let Some(raw) = node.plugin_data.get(key) else { + continue; + }; + let Ok(Value::Array(items)) = serde_json::from_str::(raw) else { + continue; + }; + for item in items.iter().take(128) { + let Some(id) = item.get("id").and_then(Value::as_str) else { + continue; + }; + let Some(family) = item.get("family").and_then(Value::as_str) else { + continue; + }; + if id.len() > 128 || family.len() > 128 || external.len() >= 256 { + continue; + } + let key = (id.to_owned(), family.to_owned()); + external.entry(key).or_default().insert(node.name.chars().take(80).collect()); + } + } + } + let external_fonts: Vec = external + .into_iter() + .map(|((id, family), layers)| { + json!({ + "source": if id.starts_with("custom-") { "penpot-custom" } else { "google-fonts" }, + "referenceKind": if id.starts_with("custom-") { "custom-original-unverified" } else { "google-fonts-reference" }, + "originNote": if id.starts_with("custom-") { + "Penpot-managed font: a matching installed/downloaded family is not proof of the original font file" + } else { + "Google Fonts provider reference; availability is reported separately" + }, + "id": id, "family": family, "layers": layers.into_iter().collect::>() + }) + }) + .collect(); + let entries = families + .into_iter() + .map(|(family, layers)| { + let lower = family.to_lowercase(); + let generic = matches!(lower.as_str(), "sans-serif" | "serif" | "monospace" | "system-ui"); + // No registered families can mean an uninitialised/no-font runtime; it is + // not evidence that all names are absent from the user's computer. + let available = if generic { + Some(true) + } else if known.is_empty() { + None + } else { + Some(known.contains(&lower)) + }; + json!({"family":family,"available":available,"layers":layers.into_iter().collect::>()}) + }) + .collect::>(); + let missing = entries.iter().filter(|v| v.get("available") == Some(&Value::Bool(false))).count(); + json!({"total": entries.len(), "missing": missing, "embeddedFontFiles": 0, + "fontLicenseNotices": 0, "fonts": entries, + "externalPenpotFontRefs": external_fonts}) +} + +/// Describe fonts unavailable on this machine, while keeping their original family names +/// editable in the document. The detected families are provided separately for testability +/// and to avoid a dependency from the format parser to the font renderer. +fn missing_penpot_font_warnings(doc: &Document, families: &[String]) -> Vec { + // An uninitialised font registry cannot prove a family is unavailable. + if families.is_empty() { + return Vec::new(); + } + let available: BTreeSet = families.iter().map(|s| s.trim().to_lowercase()).collect(); + let mut missing: BTreeMap> = BTreeMap::new(); + for node in doc.nodes() { + let NodeKind::Text(text) = &node.kind else { continue }; + let text_len = text.characters.chars().count(); + if text_len == 0 { + continue; + } + let mut covered = vec![false; text_len.min(100_000)]; + let mut used = BTreeSet::new(); + for run in &text.runs { + let start = run.start.min(covered.len()); + let end = run.end.min(covered.len()); + if start < end { + used.insert(run.style.font_family.clone()); + for flag in covered.iter_mut().take(end).skip(start) { + *flag = true; + } + } + } + if covered.iter().any(|present| !present) { + used.insert(text.style.font_family.clone()); + } + for family in used { + let family = family.trim(); + let lower = family.to_lowercase(); + if family.is_empty() || matches!(lower.as_str(), "sans-serif" | "serif" | "monospace" | "system-ui") || available.contains(&lower) { + continue; + } + let display_family: String = family.chars().filter(|c| !c.is_control()).take(160).collect(); + let display_layer: String = node.name.chars().filter(|c| !c.is_control()).take(80).collect(); + missing.entry(display_family).or_default().insert(display_layer); + } + } + // A single warning remains readable even for documents with hundreds of + // type layers and does not push out more urgent Penpot import diagnostics. + if missing.is_empty() { + return Vec::new(); + } + let count = missing.len(); + let names = missing + .into_iter() + .take(8) + .map(|(family, layers)| format!("'{family}' (e.g. '{}')", layers.iter().next().map(String::as_str).unwrap_or("text"))) + .collect::>() + .join(", "); + let extra = if count > 8 { format!(" and {} more", count - 8) } else { String::new() }; + vec![format!( + "Penpot: {count} font family/families are not installed or embedded: {names}{extra}. FigCraft uses fallback fonts for display, but preserves the original family names; install the licensed fonts and reopen the file to restore text metrics." + )] +} + /// Link imported instances to their mains (layer sources, overrides found by comparing values), /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { @@ -722,9 +987,9 @@ fn open(s: &mut Session, p: &Value) -> Result { let (loaded, rest) = load_path(&path, variables.as_ref())?; remember(s, &path); let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); - let warnings = rest || ext(&path) == "svg"; + let warnings = rest || matches!(ext(&path).as_str(), "svg" | "penpot"); // A native file that lost something (a damaged image, say): the app says so, not only the result. - if native && !loaded.issues.is_empty() { + if (native || warnings) && !loaded.issues.is_empty() { s.message = Some(match loaded.issues.as_slice() { [one] => format!("{}: {one}", file_stem(&path)), more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), @@ -745,7 +1010,7 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) let data = data.split_once(',').map_or(data, |(_, b)| b); let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; - let warnings = rest || ext(&name) == "svg"; + let warnings = rest || matches!(ext(&name).as_str(), "svg" | "penpot"); let summary = import_summary(&loaded.doc); s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); s.active = s.docs.len() - 1; @@ -1028,12 +1293,14 @@ pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option Result { let cmd = "file.export"; - let fig = match string(p, "format") { - Some(f) => f.eq_ignore_ascii_case("fig"), - None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"), + let whole_file = match string(p, "format") { + Some(f) => f.to_ascii_lowercase(), + None => string(p, "path").map(|pp| ext(&pp)).unwrap_or_default(), }; - if fig { - return export_fig(s, p); + match whole_file.as_str() { + "fig" => return export_fig(s, p), + "penpot" => return export_penpot(s, p), + _ => {} } let doc = s.doc()?; let mut ids = targets(s, p); @@ -1075,6 +1342,21 @@ fn export_fig(s: &mut Session, p: &Value) -> Result { } } +fn export_penpot(s: &mut Session, p: &Value) -> Result { + let (bytes, warnings) = penpot_bytes(s.doc()?)?; + match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len(), "format": "penpot", "warnings": warnings})) + } + None => Ok(json!({"format": "penpot", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})), + } +} + +pub fn penpot_bytes(doc: &Document) -> Result<(Vec, Vec)> { + figcraft_format::penpot::export(doc).map_err(|e| EngineError::Other(e.to_string())) +} + /// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out. pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string())) @@ -1331,7 +1613,7 @@ fn import(s: &mut Session, p: &Value) -> Result { } Ok(out) } - "figcraft" | "json" | "fig" => { + "figcraft" | "json" | "fig" | "penpot" => { let resolver = path.as_deref().map(rest_images); let images: figcraft_format::rest::ImageResolver = match &resolver { Some(r) => r, @@ -1341,8 +1623,14 @@ fn import(s: &mut Session, p: &Value) -> Result { let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; let summary = import_summary(&other.doc); let pages = append_pages(s.doc_mut()?, &other.doc)?; + if !other.issues.is_empty() { + s.message = Some(match other.issues.as_slice() { + [one] => format!("{}: {one}", file_stem(&name)), + more => format!("{}: {} import warnings; first: {}", file_stem(&name), more.len(), more[0]), + }); + } let mut out = json!({"pages": pages, "summary": summary}); - if rest { + if rest || !other.issues.is_empty() { out["warnings"] = json!(other.issues); } Ok(out) @@ -1768,3 +2056,112 @@ mod staging_write_tests { let _ = std::fs::remove_dir_all(&d); } } + +#[cfg(test)] +mod penpot_font_warning_tests { + use super::*; + + #[test] + fn font_report_explains_custom_and_google_provenance() { + let mut doc = Document::new("External font refs"); + let page = doc.first_page().unwrap(); + let text = TextData::new("Playball"); + let id = doc.create(page, NodeKind::Text(text), "Custom caption", 0., 0., 120., 30.).unwrap(); + doc.node_mut(id).unwrap().plugin_data.insert( + "penpot.customFontRefs".into(), + r#"[{"id":"custom-123abc","family":"Playball","variants":[{"weight":400,"italic":false}]}]"#.into(), + ); + let report = font_report_with_families(&doc, &[]); + assert_eq!(report["externalPenpotFontRefs"][0]["source"], "penpot-custom"); + assert_eq!(report["externalPenpotFontRefs"][0]["family"], "Playball"); + assert_eq!(report["externalPenpotFontRefs"][0]["referenceKind"], "custom-original-unverified"); + assert!(report["externalPenpotFontRefs"][0]["originNote"].as_str().unwrap().contains("not proof")); + } + + #[test] + fn reports_only_missing_families_without_modifying_original_text() { + let mut doc = Document::new("Penpot fonts"); + let page = doc.first_page().unwrap(); + let mut text = TextData::new("hello world"); + text.style.font_family = "Rare Licensed Family".into(); + doc.create(page, NodeKind::Text(text), "Headline", 0.0, 0.0, 200.0, 30.0).unwrap(); + let warnings = missing_penpot_font_warnings(&doc, &["Inter".into(), "Source Sans Pro".into()]); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("Rare Licensed Family")); + assert!(warnings[0].contains("Headline")); + assert_eq!( + doc.nodes().find(|n| n.name == "Headline").and_then(|n| match &n.kind { + NodeKind::Text(t) => Some(t.style.font_family.as_str()), + _ => None, + }), + Some("Rare Licensed Family") + ); + assert!(missing_penpot_font_warnings(&doc, &["RARE LICENSED FAMILY".into()]).is_empty()); + assert!(missing_penpot_font_warnings(&doc, &[]).is_empty()); + } + + #[test] + fn completely_styled_text_does_not_report_an_unused_base_font() { + let mut doc = Document::new("Run fonts"); + let page = doc.first_page().unwrap(); + let mut t = TextData::new("AB"); + t.style.font_family = "Unused Base".into(); + let mut run = t.style.clone(); + run.font_family = "Available Font".into(); + t.runs.push(StyleRun { start: 0, end: 2, style: run }); + doc.create(page, NodeKind::Text(t), "Full run", 0.0, 0.0, 100.0, 30.0).unwrap(); + assert!(missing_penpot_font_warnings(&doc, &["Available Font".into()]).is_empty()); + } +} + +#[cfg(test)] +mod penpot_svg_v10_tests { + use super::*; + + #[test] + fn expands_inline_svg_into_editable_shapes_without_discarding_source() { + let mut doc = Document::new("SVG fixture"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, NodeKind::Frame, "SVG ornament", 10.0, 20.0, 100.0, 50.0).unwrap(); + let svg = r#""#; + let layer = doc.node_mut(id).unwrap(); + layer.plugin_data.insert("penpot.svgRawXml".into(), svg.into()); + layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into()); + let mut warnings = Vec::new(); + expand_penpot_raw_svgs(&mut doc, &mut warnings); + let layer = doc.node(id).unwrap(); + assert_eq!(layer.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("expanded")); + assert!(!layer.children.is_empty()); + assert!(doc.nodes().any(|node| matches!(&node.kind, NodeKind::Rectangle | NodeKind::Vector { .. }))); + assert_eq!(layer.plugin_data.get("penpot.svgRawXml").map(String::as_str), Some(svg)); + } + + #[test] + fn invalid_raw_svg_keeps_placeholder_and_markup() { + let mut doc = Document::new("Invalid SVG fixture"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, NodeKind::Frame, "Broken", 0.0, 0.0, 80.0, 30.0).unwrap(); + let layer = doc.node_mut(id).unwrap(); + layer.plugin_data.insert("penpot.svgRawXml".into(), " u64 { + v["id"].as_u64().unwrap() +} + +fn temp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("figcraft-penpot-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +type Layer = (String, [f64; 4]); + +fn outline(doc: &Document) -> Vec { + doc.pages + .iter() + .flat_map(|p| doc.subtree(*p)) + .filter_map(|i| doc.node(i)) + .map(|n| { + let t = doc.world_transform(n.id).translation(); + let text = n.kind.text().map(|t| t.characters.clone()).unwrap_or_default(); + let inside = doc.ancestors(n.id).iter().any(|a| matches!(doc.node(*a).map(|x| &x.kind), Some(NodeKind::Instance { .. }))); + let label = format!( + "{} {:?} {text:?} fills {} strokes {} effects {} children {} linked {} interactions {}", + n.kind.type_name(), + n.name, + n.fills.len(), + n.strokes.len(), + n.effects.len(), + n.children.len(), + !inside || n.source.is_some(), + n.interactions.len(), + ); + (label, [t.x, t.y, n.width, n.height]) + }) + .collect() +} + +fn same(a: &[Layer], b: &[Layer]) -> Option<(Layer, Layer)> { + if a.len() != b.len() { + return a.first().cloned().zip(b.first().cloned()); + } + a.iter().zip(b).find(|(x, y)| x.0 != y.0 || x.1.iter().zip(y.1).any(|(p, q)| (p - q).abs() > 0.01)).map(|(x, y)| (x.clone(), y.clone())) +} + +fn build(s: &mut Session) { + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + s.execute("text.create", &json!({"text": "Hello 😀", "x": 10, "y": 10, "parent": frame})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 40, "width": 50, "height": 20, "parent": frame})).unwrap(); + s.execute("layout.add", &json!({"ids": [frame], "direction": "vertical"})).unwrap(); + let card = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 120, "height": 60})).unwrap()); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": card})).unwrap(); + let comp = id(&s.execute("component.create", &json!({"ids": [card]})).unwrap()); + let inst = id(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 200})).unwrap()); + s.execute("proto.interaction.add", &json!({"id": frame, "dest": inst})).unwrap(); +} + +#[test] +fn exported_penpot_files_open_with_the_same_layers() { + let mut s = Session::new(); + build(&mut s); + let path = temp("roundtrip.penpot"); + let r = s.execute("file.export", &json!({"format": "penpot", "path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["format"], "penpot"); + assert!(r["warnings"].is_array()); + let before = outline(s.doc().unwrap()); + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(same(&outline(doc), &before), None); + let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + assert!(inst.children.iter().all(|c| doc.node(*c).is_some_and(|c| c.source.is_some()))); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn penpot_export_without_a_path_returns_the_file() { + let mut s = Session::new(); + build(&mut s); + let r = s.execute("file.export", &json!({"format": "PENPOT"})).unwrap(); + let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap(); + let (doc, _) = crate::cmd::file::load_bytes(&bytes, "x.penpot").unwrap(); + assert_eq!(same(&outline(&doc), &outline(s.doc().unwrap())), None); + let path = temp("by-extension.penpot"); + let r = s.execute("file.export", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["format"], "penpot"); + let _ = std::fs::remove_file(&path); +} + +/// Provider references remain diagnostic metadata; open/import never retrieve font bytes. +#[test] +fn missing_provider_fonts_stay_offline_and_text_stays_editable() { + let mut source = Document::new("Offline font"); + let page = source.first_page().unwrap(); + let mut text = figcraft_doc::TextData::new("Offline text"); + text.style.font_family = "FigCraft Missing Provider Q7".into(); + source.create(page, NodeKind::Text(text), "Label", 0.0, 0.0, 200.0, 30.0).unwrap(); + let bytes = figcraft_format::penpot::export(&source).unwrap().0; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + for command in ["file.open", "file.import"] { + let mut s = Session::new(); + let result = s.execute(command, &json!({"name": "offline.penpot", "data": data})).unwrap(); + let doc = s.doc().unwrap(); + let label = doc.nodes().find(|n| n.name == "Label").unwrap(); + assert_eq!(label.kind.text().unwrap().style.font_family, "FigCraft Missing Provider Q7"); + assert_eq!(label.kind.text().unwrap().characters, "Offline text"); + assert!(label.plugin_data.contains_key("penpot.googleFontRefs")); + assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("Missing")))); + } +} + +#[test] +fn penpot_export_survives_junk_params() { + let junk = [ + json!({"format": "penpot", "path": [5]}), + json!({"format": "penpot", "path": ""}), + json!({"format": "penpot", "path": "\u{0}/x.penpot"}), + json!({"format": "penpot", "ids": ["x"], "scale": "NaN"}), + json!({"format": ["penpot"]}), + json!({"path": "nowhere/../\u{0}.penpot"}), + ]; + for j in &junk { + let mut s = Session::new(); + build(&mut s); + if let Err(EngineError::Internal { cmd, msg }) = s.execute("file.export", j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + let mut s = Session::new(); + assert!(s.execute("file.export", &json!({"format": "penpot"})).is_ok()); +} + +#[test] +fn local_penpot_samples_export_and_open_again() { + let Some(dir) = std::env::var_os("FIGCRAFT_PENPOT_SAMPLES") else { return }; + let Ok(dir) = std::fs::read_dir(dir) else { return }; + for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "penpot")) { + let mut s = Session::new(); + s.execute("file.open", &json!({"path": p.to_string_lossy()})).unwrap(); + let before = outline(s.doc().unwrap()); + let out = temp(&format!("again-{}", p.file_name().unwrap().to_string_lossy())); + s.execute("file.export", &json!({"format": "penpot", "path": out.to_string_lossy()})).unwrap(); + s.execute("file.open", &json!({"path": out.to_string_lossy()})).unwrap(); + let after = outline(s.doc().unwrap()); + assert_eq!(same(&before, &after), None, "{}: {} → {} layers", p.display(), before.len(), after.len()); + let _ = std::fs::remove_file(&out); + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 3dca39126..80a943e2e 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] figcraft-doc = { workspace = true } figcraft-pathops = { workspace = true } +figcraft-layout = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 36975f56b..ca37e566b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -19,6 +19,7 @@ pub mod fig; pub mod library; +pub mod penpot; pub mod rest; pub mod thumbnail; diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs new file mode 100644 index 000000000..6ecab4a79 --- /dev/null +++ b/crates/format/src/penpot.rs @@ -0,0 +1,6481 @@ +//! Native, offline importer for Penpot 2.x `.penpot` exports (ZIP / JSON). +//! +//! The archive contains `manifest.json`, one JSON per page and shape, and image blobs in +//! `objects/`. Imported shapes are ordinary FigCraft nodes, not a flattened SVG. +//! Only the supported subset is mapped; unknown properties are not silently represented as +//! fully editable. This module has no Python, network or UI dependency and works with WASM. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, Cursor, Read}; + +use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind}; +use figcraft_doc::geom::{Affine, BezPath, Shape}; +use figcraft_doc::{ + AutoLayout, Blur, BoolOp, CellAlign, Collection, ContentAlign, CrossAlign, Document, Effect, EffectKind, GridAlign, GridKind, GridTrack, Guide, + HConstraint, LayoutGrid, LayoutMode, LetterSpacing, LineHeight, MainAlign, MaskType, Mode, NodeId, NodeKind, Shadow, Sizing, StrokeAlign, + StrokeCap, StrokeJoin, Style, StyleKind, StyleRun, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, + Variable, VariableType, VariableValue, VectorNetwork, +}; +use serde_json::Value; +use zip::ZipArchive; + +use crate::{FormatError, add_image}; + +mod embedded_fonts; +mod export; +#[cfg(test)] +mod export_tests; +mod prototype; + +pub use export::export; + +const MAX_JSON: u64 = 16 * 1024 * 1024; +const MAX_JSON_VALUES: usize = 4_000_000; +const MAX_PAGE_SHAPES_BYTES: u64 = 128 * 1024 * 1024; +const MAX_IMAGE: u64 = 128 * 1024 * 1024; +const MAX_ARCHIVE: usize = 512 * 1024 * 1024; +const MAX_DECOMPRESSED: u64 = 1024 * 1024 * 1024; +const MAX_ENTRIES: usize = 100_000; +const MAX_LAYERS: usize = 50_000; +const MAX_TOTAL_IMAGES: usize = 512 * 1024 * 1024; +const MAX_DEPTH: usize = 128; +const ROOT_ID: &str = "00000000-0000-0000-0000-000000000000"; + +/// Report a malformed archive through the shared format error type. +fn corrupt(msg: impl Into) -> FormatError { + FormatError::Corrupt(format!("Penpot: {}", msg.into())) +} + +/// Accept UUID-shaped identifiers before using them in archive member paths. +fn uuid(s: &str) -> bool { + s.len() == 36 && s.bytes().enumerate().all(|(i, b)| if [8, 13, 18, 23].contains(&i) { b == b'-' } else { b.is_ascii_hexdigit() }) +} + +/// Counts actual ZIP output and JSON structure throughout one import, independently +/// of member header sizes and the serialized size of dense arrays/objects. +struct InflateBudget { + remaining: u64, + exceeded: bool, + json_values: usize, + json_exceeded: bool, +} + +impl InflateBudget { + fn new() -> Self { + Self { remaining: MAX_DECOMPRESSED, exceeded: false, json_values: MAX_JSON_VALUES, json_exceeded: false } + } + + fn check(&self) -> Result<(), FormatError> { + if self.exceeded { + Err(corrupt("actual decompressed ZIP contents exceed 1 GiB")) + } else if self.json_exceeded { + Err(corrupt("JSON structure exceeds import value budget")) + } else { + Ok(()) + } + } + + /// Admit JSON before serde allocates its tree. Outside strings, each container, + /// comma and colon covers at least one value or object key; counting one root + /// as well deliberately overestimates structure. Serde still validates syntax. + fn parse_json(&mut self, data: &[u8], path: &str) -> Result { + self.check()?; + let mut values = 1; + let (mut quoted, mut escaped) = (false, false); + for &byte in data { + if quoted { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + quoted = false; + } + } else if byte == b'"' { + quoted = true; + } else if matches!(byte, b'[' | b'{' | b',' | b':') { + values += 1; + } + } + if values > self.json_values { + self.json_exceeded = true; + return Err(corrupt("JSON structure exceeds import value budget")); + } + self.json_values -= values; + serde_json::from_slice(data).map_err(|e| corrupt(format!("{path}: {e}"))) + } +} + +struct BudgetReader<'a, R> { + inner: R, + budget: &'a mut InflateBudget, +} + +impl Read for BudgetReader<'_, R> { + fn read(&mut self, out: &mut [u8]) -> io::Result { + if out.is_empty() { + return Ok(0); + } + if self.budget.remaining == 0 { + // Distinguish exact-budget EOF from data past the limit. + let mut probe = [0_u8; 1]; + return match self.inner.read(&mut probe)? { + 0 => Ok(0), + _ => { + self.budget.exceeded = true; + Err(io::Error::other("Penpot decompression budget exhausted")) + } + }; + } + let limit = out.len().min(usize::try_from(self.budget.remaining).unwrap_or(usize::MAX)); + let read = self.inner.read(&mut out[..limit])?; + self.budget.remaining -= read as u64; + Ok(read) + } +} + +/// Read a JSON archive member within the per-member decompression limit. +fn json(z: &mut ZipArchive>, path: &str, budget: &mut InflateBudget) -> Result { + let entry = z.by_name(path).map_err(|e| corrupt(format!("{path}: {e}")))?; + if entry.size() > MAX_JSON { + return Err(corrupt(format!("JSON member too large: {path}"))); + } + let mut data = Vec::new(); + BudgetReader { inner: entry, budget }.take(MAX_JSON + 1).read_to_end(&mut data).map_err(|e| corrupt(e.to_string()))?; + if data.len() as u64 > MAX_JSON { + return Err(corrupt(format!("JSON member too large: {path}"))); + } + budget.parse_json(&data, path) +} + +/// Read a shape JSON member within member and per-page decompression limits. +fn shape_json(z: &mut ZipArchive>, path: &str, budget: &mut InflateBudget, page_budget: &mut u64) -> Result { + let entry = z.by_name(path).map_err(|e| corrupt(format!("{path}: {e}")))?; + if entry.size() > MAX_JSON { + return Err(corrupt(format!("JSON member too large: {path}"))); + } + if entry.size() > *page_budget { + return Err(corrupt("page shapes exceed byte budget")); + } + let mut data = Vec::new(); + let max_read = (MAX_JSON + 1).min(*page_budget + 1); + BudgetReader { inner: entry, budget }.take(max_read).read_to_end(&mut data).map_err(|e| corrupt(e.to_string()))?; + if data.len() as u64 > MAX_JSON { + return Err(corrupt(format!("JSON member too large: {path}"))); + } + if data.len() as u64 > *page_budget { + return Err(corrupt("page shapes exceed byte budget")); + } + *page_budget -= data.len() as u64; + budget.parse_json(&data, path) +} + +/// Read a finite numeric field or use the supplied fallback. +fn number(v: &Value, key: &str, default: f64) -> f64 { + let n = v.get(key).and_then(|n| n.as_f64().or_else(|| n.as_str().and_then(|s| s.parse().ok()))).unwrap_or(default); + if n.is_finite() { n.clamp(-1_000_000.0, 1_000_000.0) } else { default } +} +/// Read geometry from the shape or its saved selection rectangle. +fn geometry(v: &Value, key: &str, default: f64) -> f64 { + match v.get(key) { + Some(n) if !n.is_null() => number(v, key, default), + _ => v.get("selrect").map_or(default, |s| number(s, key, default)), + } +} +/// Borrow a string field without coercing other JSON types. +fn string<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + v.get(key).and_then(Value::as_str) +} +/// Iterate an array field, treating absent or malformed arrays as empty. +fn list<'a>(v: &'a Value, key: &str) -> impl Iterator { + v.get(key).and_then(Value::as_array).into_iter().flatten() +} + +/// Penpot ruler guides can be keyed objects or arrays. An `x` guide is a vertical +/// line; the coordinate is in the containing canvas/frame coordinate system. +/// Unknown axes and malformed positions are not imported as misleading guides. +fn penpot_guides(value: Option<&Value>) -> Vec { + let Some(value) = value else { return Vec::new() }; + let items: Box + '_> = match value { + Value::Array(items) => Box::new(items.iter()), + Value::Object(items) => Box::new(items.values()), + _ => return Vec::new(), + }; + items + .take(500) + .filter_map(|item| { + let vertical = match string(item, "axis").or_else(|| string(item, "orientation"))? { + "x" | "vertical" => true, + "y" | "horizontal" => false, + _ => return None, + }; + let raw = item.get("position").or_else(|| item.get("offset"))?; + let offset = raw.as_f64().or_else(|| raw.as_str().and_then(|s| s.parse::().ok()))?; + (offset.is_finite() && offset.abs() <= 1_000_000.0).then_some(Guide { vertical, offset }) + }) + .collect() +} + +/// Non-layout display grids shown on Penpot boards. These are separate from the +/// Flex/Grid auto-layout system. Unknown grids remain in source metadata. +fn display_grids(v: &Value, warnings: &mut Vec) -> Vec { + let mut result = Vec::new(); + for grid in list(v, "grids").take(64) { + let params = grid.get("params").unwrap_or(grid); + let kind = match string(grid, "type") { + Some("square") => GridKind::Grid, + Some("column" | "columns") => GridKind::Columns, + Some("row" | "rows") => GridKind::Rows, + _ => { + warn(warnings, "Unsupported Penpot display-grid type; original grid retained as metadata".into()); + continue; + } + }; + let mut native = match kind { + GridKind::Grid => LayoutGrid::default(), + GridKind::Columns => LayoutGrid::columns(), + GridKind::Rows => LayoutGrid::rows(), + }; + native.kind = kind; + native.visible = grid.get("display").and_then(Value::as_bool) != Some(false); + if kind == GridKind::Grid { + native.size = number(params, "size", native.size).clamp(0.0, 100_000.0); + } else { + native.size = number(params, "itemLength", native.size).clamp(0.0, 100_000.0); + native.count = params + .get("size") + .or_else(|| params.get("count")) + .and_then(Value::as_f64) + .filter(|n| n.is_finite() && *n >= 0.0) + .map_or(native.count, |n| n.min(10_000.0) as u32); + } + native.gutter = number(params, "gutter", native.gutter).clamp(0.0, 100_000.0); + native.offset = number(params, "margin", native.offset); + native.align = match string(params, "type").or_else(|| string(params, "align")) { + Some("left" | "top" | "start") => GridAlign::Min, + Some("center") => GridAlign::Center, + Some("right" | "bottom" | "end") => GridAlign::Max, + _ => GridAlign::Stretch, + }; + if let Some(c) = params.get("color").or_else(|| grid.get("color")) + && let Some(hex) = c.as_str().or_else(|| string(c, "color")) + && let Some(color) = Color::from_hex(hex) + { + native.color = color.with_alpha((color.a as f64 * number(c, "opacity", 1.0).clamp(0.0, 1.0)) as f32); + } + result.push(native); + } + result +} + +/// Resolve grid placement and track sizing after the children have been imported. +fn finish_layout(doc: &mut Document, frame: NodeId, v: &Value, warnings: &mut Vec) { + let Some(f) = doc.node(frame) else { return }; + let Some(al) = f.layout.clone() else { return }; + let kids: Vec = f.children.iter().filter_map(|id| doc.node(*id)).filter(|n| !n.child_layout.absolute).cloned().collect(); + if kids.iter().any(|n| n.plugin_data.contains_key("penpot.unsupported.itemLayout")) { + if let Some(n) = doc.node_mut(frame) { + n.layout = None; + } + warn(warnings, "Penpot item margins or self-alignment are unsupported; saved positions and source metadata were kept".into()); + return; + } + let (width, height, hug_w, hug_h) = (f.width, f.height, f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug); + let [pt, pr, pb, pl] = al.padding; + let mut al = al; + match al.mode { + LayoutMode::Horizontal | LayoutMode::Vertical => { + let horizontal = al.mode == LayoutMode::Horizontal; + let grows = kids.iter().any(|n| if horizontal { n.child_layout.sizing_h } else { n.child_layout.sizing_v } == Sizing::Fill); + if grows && matches!(al.main_align, MainAlign::SpaceBetween | MainAlign::SpaceAround | MainAlign::SpaceEvenly) { + al.main_align = MainAlign::Min; + } + } + LayoutMode::Grid => { + if string(v, "layoutJustifyContent") == Some("stretch") && !hug_w { + let content = kids + .iter() + .filter(|n| n.child_layout.column_span() == 1 && n.child_layout.sizing_h != Sizing::Fill) + .filter_map(|n| Some((n.child_layout.grid_column?, n.width))); + al.column_tracks = stretch_auto_tracks(&al.column_tracks, content, width - pl - pr, al.gap); + } + if string(v, "layoutAlignContent") == Some("stretch") && !hug_h { + let content = kids + .iter() + .filter(|n| n.child_layout.row_span() == 1 && n.child_layout.sizing_v != Sizing::Fill) + .filter_map(|n| Some((n.child_layout.grid_row?, n.height))); + al.row_tracks = stretch_auto_tracks(&al.row_tracks, content, height - pt - pb, al.cross_gap); + } + } + } + if let Some(n) = doc.node_mut(frame) { + n.layout = Some(al); + } +} + +/// Convert auto tracks to proportional fill tracks using their measured content. +fn stretch_auto_tracks(tracks: &[GridTrack], content: impl Iterator, inner: f64, gap: f64) -> Vec { + if tracks.iter().any(|t| t.sizing == Sizing::Fill) { + return tracks.to_vec(); + } + let mut sizes: Vec = tracks.iter().map(|t| if t.sizing == Sizing::Fixed { t.value } else { 0.0 }).collect(); + for (i, size) in content { + if let (Some(s), Some(GridTrack { sizing: Sizing::Hug, .. })) = (sizes.get_mut(i as usize), tracks.get(i as usize)) { + *s = s.max(size); + } + } + let autos = tracks.iter().filter(|t| t.sizing == Sizing::Hug).count(); + let free = inner - sizes.iter().sum::() - gap * tracks.len().saturating_sub(1) as f64; + if autos == 0 || !free.is_finite() || free <= 0.0 { + return tracks.to_vec(); + } + let share = free / autos as f64; + tracks.iter().zip(&sizes).map(|(t, s)| if t.sizing == Sizing::Hug { GridTrack::fill(s + share) } else { *t }).collect() +} + +/// Convert a supported group mask and report masks that cannot be represented. +fn finish_masked_group(doc: &mut Document, group: NodeId, source: &Value, warnings: &mut Vec) { + if source.get("maskedGroup").and_then(Value::as_bool) != Some(true) { + return; + } + let Some(children) = doc.node(group).map(|n| n.children.clone()) else { + return; + }; + let first = list(source, "shapes").filter_map(Value::as_str).next(); + let mask = first + .and_then(|first| children.iter().copied().find(|&id| doc.node(id).and_then(|n| n.plugin_data.get("penpot.id")).is_some_and(|p| p == first))); + let Some(mask) = mask else { + warn(warnings, "Penpot masked group has no first layer to use as its mask; original group preserved".into()); + return; + }; + for &id in &children { + if let Some(child) = doc.node_mut(id) { + child.is_mask = id == mask; + } + } + if let Some(node) = doc.node_mut(group) { + node.children.retain(|id| *id != mask); + node.children.insert(0, mask); + node.plugin_data.insert("penpot.maskImported".into(), "true".into()); + } +} + +/// Read one exported `.penpot` archive without touching the filesystem or running a converter. +/// Returns native, editable FigCraft nodes plus notes about unsupported features. +pub fn import(bytes: &[u8]) -> Result<(Document, Vec), FormatError> { + if bytes.len() > MAX_ARCHIVE { + return Err(corrupt("archive exceeds 512 MiB")); + } + let mut z = ZipArchive::new(Cursor::new(bytes)).map_err(|e| corrupt(format!("invalid ZIP: {e}")))?; + if z.len() > MAX_ENTRIES { + return Err(corrupt("too many ZIP members")); + } + // Reject oversized logical archives before decoding any JSON. This guards against ZIP bombs + // whose compressed size is innocuous but whose advertised uncompressed size is enormous. + let mut expanded = 0_u64; + let mut names = BTreeSet::new(); + for index in 0..z.len() { + let member = z.by_index(index).map_err(|e| corrupt(e.to_string()))?; + // An importer never extracts members to disk, but ambiguous ZIP names can + // cause one member to be inspected and another member to be read by name. + // Reject duplicates and traversal instead of guessing which version is real. + let name = member.name(); + if !names.insert(name.to_owned()) { + return Err(corrupt(format!("duplicate ZIP member: {}", name.chars().take(200).collect::()))); + } + if name.starts_with('/') + || name.contains('\\') + || name.split('/').any(|part| part == "." || part == "..") + || name.chars().any(char::is_control) + { + return Err(corrupt("unsafe ZIP member path")); + } + expanded = expanded.saturating_add(member.size()); + if expanded > MAX_DECOMPRESSED { + return Err(corrupt("ZIP contents exceed 1 GiB")); + } + } + let mut inflate_budget = InflateBudget::new(); + let manifest = json(&mut z, "manifest.json", &mut inflate_budget)?; + if string(&manifest, "type") != Some("penpot/export-files") { + return Err(corrupt("not a Penpot export-files archive")); + } + if manifest.get("version").and_then(Value::as_u64) != Some(1) { + return Err(corrupt("unsupported Penpot export version")); + } + let files = manifest.get("files").and_then(Value::as_array).ok_or_else(|| corrupt("no files in manifest"))?; + if files.is_empty() || files.len() > 50 { + return Err(corrupt("invalid number of Penpot files")); + } + + let initial_name = files.first().and_then(|f| string(f, "name")).unwrap_or("Penpot import"); + let mut doc = Document::without_pages(initial_name.chars().take(256).collect::()); + let mut warnings = Vec::new(); + embedded_fonts::report(&mut z, &mut warnings, &mut inflate_budget); + inflate_budget.check()?; + let mut image_keys: BTreeMap = BTreeMap::new(); + let mut layer_count = 0; + let mut image_bytes = 0usize; + let mut svg_bytes = 0usize; + let mut token_sources = Vec::new(); + let mut active_modes = BTreeMap::new(); + + for file in files { + let Some(file_id) = string(file, "id").filter(|id| uuid(id)) else { + return Err(corrupt("invalid file UUID")); + }; + let base = format!("files/{file_id}"); + let (masters, variant_names, component_sources) = component_masters(&mut z, &base, &mut inflate_budget); + let file_header = json(&mut z, &format!("{base}.json"), &mut inflate_budget) + .ok() + .map(|header| { + let kept: serde_json::Map = + FILE_HEADER_KEYS.iter().filter_map(|key| Some(((*key).to_owned(), header.get(*key)?.clone()))).collect(); + Value::Object(kept) + }) + .and_then(|kept| serde_json::to_string(&kept).ok()) + .filter(|encoded| encoded.len() <= 16_384); + // Library swatches remain useful as native document colors even when a + // Penpot shared-style/variable reference has no FigCraft equivalent. + import_document_colors(&mut z, &base, &mut doc, &mut warnings, &mut inflate_budget); + let library_font_refs = import_typography_styles(&mut z, &base, &mut doc, &mut warnings, &mut inflate_budget); + if let Some(source) = import_token_variables(&mut z, &base, &mut doc, &mut active_modes, &mut warnings, &mut inflate_budget) { + token_sources.push((file_id.to_owned(), source)); + } + inflate_budget.check()?; + // The page index files are peers of the directories containing their layers. + let prefix = format!("{base}/pages/"); + let page_names: Vec = z + .file_names() + .filter(|name| { + name.strip_prefix(&prefix).is_some_and(|part| part.len() == 41 && part.ends_with(".json") && part.get(..36).is_some_and(uuid)) + }) + .take(501) + .map(str::to_owned) + .collect(); + if page_names.len() > 500 { + return Err(corrupt("too many pages")); + } + let mut pages = Vec::new(); + for path in page_names { + let value = json(&mut z, &path, &mut inflate_budget)?; + let Some(id) = string(&value, "id").filter(|id| uuid(id)) else { continue }; + let name = string(&value, "name").unwrap_or("Page").chars().take(256).collect::(); + let index = value.get("index").and_then(Value::as_i64).unwrap_or(i64::MAX); + let bg = string(&value, "background").and_then(Color::from_hex); + let guides = penpot_guides(value.get("guides").or_else(|| value.pointer("/options/guides"))); + // The page's flows name the frames the prototype starts at; they are read once + // every page is in the document, when the frame ids can be resolved. + let flows = value.get("flows").cloned(); + pages.push((index, id.to_owned(), name, bg, guides, flows)); + } + pages.sort_by_key(|p| p.0); + let mut library_refs_attached = false; + let mut file_header = file_header; + for (_, page_id, page_name, bg, guides, flows) in pages { + let page = doc.add_page(&page_name); + if let Some(node) = doc.node_mut(page) { + node.plugin_data.insert("penpot.pageId".into(), page_id.clone()); + node.plugin_data.insert("penpot.fileId".into(), file_id.to_owned()); + if let Some(header) = file_header.take() { + node.plugin_data.insert("penpot.source.file".into(), header); + } + } + if !library_refs_attached && !library_font_refs.is_empty() { + if let Some(node) = doc.node_mut(page) { + node.plugin_data.insert("penpot.customLibraryFontRefs".into(), serde_json::to_string(&library_font_refs).unwrap_or_default()); + } + library_refs_attached = true; + } + if let Some(node) = doc.node_mut(page) + && let NodeKind::Page { guides: page_guides, .. } = &mut node.kind + { + *page_guides = guides; + } + if let Some(bg) = bg + && let Some(node) = doc.node_mut(page) + && let NodeKind::Page { background, .. } = &mut node.kind + { + *background = bg; + } + // The page's flows name the frames the prototype starts at. They are read once + // every page is in the document, when the frame ids can be resolved. + if let Some(flows) = flows + && let Ok(encoded) = serde_json::to_string(&flows) + && encoded.len() <= 16_384 + && let Some(node) = doc.node_mut(page) + { + node.plugin_data.insert("penpot.flows".into(), encoded); + } + let page_prefix = format!("{prefix}{page_id}/"); + let paths: Vec = z + .file_names() + .filter(|name| { + name.strip_prefix(&page_prefix) + .is_some_and(|part| part.len() == 41 && part.ends_with(".json") && part.get(..36).is_some_and(uuid)) + }) + .take(MAX_LAYERS + 1) + .map(str::to_owned) + .collect(); + if paths.len() > MAX_LAYERS { + return Err(corrupt("too many layers on a page")); + } + let mut shapes = BTreeMap::new(); + let mut page_shape_budget = MAX_PAGE_SHAPES_BYTES; + for path in paths { + let shape = shape_json(&mut z, &path, &mut inflate_budget, &mut page_shape_budget)?; + if let Some(id) = string(&shape, "id").filter(|id| uuid(id)) { + shapes.insert(id.to_owned(), shape); + } + } + let Some(root) = shapes.get(ROOT_ID) else { + return Err(corrupt(format!("page {page_name} has no root frame"))); + }; + // The root frame represents the page canvas rather than a visible 0.01 px frame. + if let Some(c) = list(root, "fills").find_map(|f| string(f, "fillColor").and_then(Color::from_hex)) + && let Some(node) = doc.node_mut(page) + && let NodeKind::Page { background, .. } = &mut node.kind + { + *background = c; + } + // The reverse parent index lets us recover children omitted from `shapes` + // without a quadratic scan for every nested group (large files have many layers). + // Declared stacking order always wins; recovered siblings are deterministic. + let mut children_by_parent: BTreeMap> = BTreeMap::new(); + for (shape_id, shape) in &shapes { + if shape_id != ROOT_ID + && let Some(pid) = string(shape, "parentId").filter(|id| uuid(id)) + { + children_by_parent.entry(pid.to_owned()).or_default().push(shape_id.clone()); + } + } + let svg_defs = svg_gradients(&shapes); + let roots: Vec = list(root, "shapes").filter_map(Value::as_str).map(str::to_owned).collect(); + let mut seen = BTreeSet::new(); + for id in roots { + add_layer( + &mut z, + &base, + &shapes, + &children_by_parent, + &svg_defs, + &masters, + &id, + page, + Affine::IDENTITY, + None, + &mut doc, + &mut image_keys, + &mut image_bytes, + &mut inflate_budget, + &mut svg_bytes, + &mut warnings, + &mut seen, + 0, + &mut layer_count, + )?; + } + // Recover root children not listed in the export's stacking-order array. + if let Some(unlinked) = children_by_parent.get(ROOT_ID) { + for id in unlinked { + if !seen.contains(id) { + warn(&mut warnings, "Penpot page omitted a top-level child from its stacking list; recovered it by parentId".into()); + add_layer( + &mut z, + &base, + &shapes, + &children_by_parent, + &svg_defs, + &masters, + id, + page, + Affine::IDENTITY, + None, + &mut doc, + &mut image_keys, + &mut image_bytes, + &mut inflate_budget, + &mut svg_bytes, + &mut warnings, + &mut seen, + 0, + &mut layer_count, + )?; + } + } + } + // Broken exports can contain valid shape JSON with a missing, invalid, + // or cyclic parent chain. Earlier versions silently omitted those + // objects because walking from the page root never reached them. + // First recover the true orphan roots, preserving their descendants + // with the original global Penpot coordinates. + for (id, shape) in &shapes { + if id == ROOT_ID || seen.contains(id) { + continue; + } + let parent_id = string(shape, "parentId"); + let parent_exists = parent_id.is_some_and(|pid| pid != id.as_str() && shapes.contains_key(pid)); + if !parent_exists { + warn( + &mut warnings, + format!( + "Penpot orphan layer '{}' has no reachable parent; attached to page", + string(shape, "name").unwrap_or(id).chars().take(80).collect::() + ), + ); + add_layer( + &mut z, + &base, + &shapes, + &children_by_parent, + &svg_defs, + &masters, + id, + page, + Affine::IDENTITY, + None, + &mut doc, + &mut image_keys, + &mut image_bytes, + &mut inflate_budget, + &mut svg_bytes, + &mut warnings, + &mut seen, + 0, + &mut layer_count, + )?; + } + } + // Remaining disconnected subgraphs can be caused by cycles or a + // parent chain deeper than MAX_DEPTH. Import one deterministic root + // for each instead of dropping editable content without warning. + for id in shapes.keys() { + if id != ROOT_ID && !seen.contains(id) { + warn(&mut warnings, format!("Penpot disconnected/cyclic layer graph at {id}; recovered as a page layer")); + add_layer( + &mut z, + &base, + &shapes, + &children_by_parent, + &svg_defs, + &masters, + id, + page, + Affine::IDENTITY, + None, + &mut doc, + &mut image_keys, + &mut image_bytes, + &mut inflate_budget, + &mut svg_bytes, + &mut warnings, + &mut seen, + 0, + &mut layer_count, + )?; + } + } + } + inflate_budget.check()?; + name_variants(&mut doc, file_id, &variant_names); + keep_component_sources(&mut doc, file_id, &component_sources); + } + inflate_budget.check()?; + if doc.pages.is_empty() { + return Err(corrupt("no pages found")); + } + doc.current_page = doc.first_page(); + // Penpot's multidimensional themes and original token aliases cannot safely be + // flattened to a single FigCraft mode. Store the bounded original on the first + // page, which survives saving as .figcraft; never claim live shape bindings. + if let Some(page) = doc.first_page() + && let Some(node) = doc.node_mut(page) + { + for (file, source) in token_sources { + node.plugin_data.insert(format!("penpot.tokens.{file}"), source); + } + } + for page in doc.pages.clone() { + if let Some(node) = doc.node_mut(page) { + node.var_modes.extend(active_modes.clone()); + } + } + doc.repair(); + link_local_instances(&mut doc, &mut warnings); + // Map safe, unambiguous Penpot applied-token references to native bindings + // after the instance graph is built, without changing the imported paint/geometry. + bind_applied_tokens(&mut doc, &active_modes, &mut warnings); + // The prototype links name shapes by their Penpot ids, which only resolve once every + // page is in the document. + import_prototype_links(&mut doc, &mut warnings); + let structural_issues = doc.validate(); + if !structural_issues.is_empty() { + warn( + &mut warnings, + format!("FigCraft document has {} remaining structural issue(s) after Penpot import; review before editing", structural_issues.len()), + ); + } + report_unsupported_layout(&doc, &mut warnings); + report_design_system_limits(&doc, &mut warnings); + record_fingerprints(&mut doc); + Ok((doc, warnings)) +} + +const FILE_HEADER_KEYS: &[&str] = &["features", "version", "migrations", "options", "isShared", "createdAt", "modifiedAt"]; + +/// Hash serialized native state so export can detect edits to imported properties. +pub(crate) fn fingerprint(value: &impl serde::Serialize) -> Option { + let bytes = serde_json::to_vec(value).ok()?; + Some(crate::image_hash(&bytes).chars().take(32).collect()) +} + +/// Fingerprint a layer layout together with its child layout settings. +pub(crate) fn layout_fingerprint(doc: &Document, id: NodeId) -> Option { + let node = doc.node(id)?; + let children: Vec = + node.children.iter().filter_map(|c| doc.node(*c)).map(|c| serde_json::json!([c.plugin_data.get("penpot.id"), c.child_layout])).collect(); + fingerprint(&serde_json::json!({"layout": node.layout, "children": children})) +} + +/// Fingerprint native text and its paints for source-preserving export. +pub(crate) fn text_fingerprint(node: &figcraft_doc::Node) -> Option { + match &node.kind { + NodeKind::Text(text) => fingerprint(&serde_json::json!({"text": text, "fills": node.fills})), + _ => None, + } +} + +/// Fingerprint stroke paints and stroke geometry settings. +pub(crate) fn strokes_fingerprint(node: &figcraft_doc::Node) -> Option { + fingerprint(&serde_json::json!({"strokes": node.strokes, "stroke": node.stroke})) +} + +/// Fingerprint native variables for source-preserving token export. +pub(crate) fn tokens_fingerprint(doc: &Document) -> Option { + fingerprint(&doc.variables) +} + +/// Store import-time fingerprints used to preserve unchanged Penpot source. +fn record_fingerprints(doc: &mut Document) { + let mut found = Vec::new(); + for node in doc.nodes() { + let layout = node.plugin_data.contains_key("penpot.source.layout").then(|| layout_fingerprint(doc, node.id)).flatten(); + let text = node.plugin_data.contains_key("penpot.source.content").then(|| text_fingerprint(node)).flatten(); + let interactions = node.plugin_data.contains_key("penpot.source.interactions").then(|| fingerprint(&node.interactions)).flatten(); + let strokes = node.plugin_data.contains_key("penpot.source.strokes").then(|| strokes_fingerprint(node)).flatten(); + if layout.is_some() || text.is_some() || interactions.is_some() || strokes.is_some() { + found.push((node.id, layout, text, interactions, strokes)); + } + } + for (id, layout, text, interactions, strokes) in found { + if let Some(node) = doc.node_mut(id) { + for (key, value) in + [("penpot.fp.layout", layout), ("penpot.fp.text", text), ("penpot.fp.interactions", interactions), ("penpot.fp.strokes", strokes)] + { + if let Some(value) = value { + node.plugin_data.insert(key.into(), value); + } + } + } + } + let tokens = tokens_fingerprint(doc); + if let Some(page) = doc.first_page() + && let Some(tokens) = tokens + && let Some(node) = doc.node_mut(page) + && node.plugin_data.keys().any(|k| k.starts_with("penpot.tokens.")) + { + node.plugin_data.insert("penpot.fp.tokens".into(), tokens); + } +} + +/// Retain original component JSON for exporting imported component definitions. +fn keep_component_sources(doc: &mut Document, file_id: &str, sources: &BTreeMap) { + let ids: Vec<(NodeId, String)> = doc + .nodes() + .filter(|n| matches!(n.kind, NodeKind::Component { .. })) + .filter(|n| n.plugin_data.get("penpot.fileId").map(String::as_str) == Some(file_id)) + .filter_map(|n| Some((n.id, sources.get(n.plugin_data.get("penpot.id")?)?.clone()))) + .collect(); + for (id, source) in ids { + if let Some(node) = doc.node_mut(id) { + node.plugin_data.insert("penpot.source.component".into(), source); + } + } +} + +/// Turn the imported Penpot prototype links into working FigCraft interactions and flows. +/// +/// Every shape keeps its Penpot interactions in its metadata, and every page its Penpot +/// flows. Now that the whole document is in, the Penpot shape ids they name can be +/// resolved to FigCraft node ids. +fn import_prototype_links(doc: &mut Document, warnings: &mut Vec) { + let by_penpot_id: BTreeMap = doc.nodes().filter_map(|n| Some((n.plugin_data.get("penpot.id")?.clone(), n.id))).collect(); + prototype::resolve_destinations(doc, &by_penpot_id, warnings); + // The pages' flows name the frames the prototype starts at. + let pages: Vec = doc.pages.clone(); + for page in pages { + let Some(raw) = doc.node(page).and_then(|n| n.plugin_data.get("penpot.flows")).cloned() else { continue }; + let Ok(flows) = serde_json::from_str::(&raw) else { continue }; + let read = prototype::page_flows(doc, &flows, &by_penpot_id, warnings); + if read.is_empty() { + continue; + } + if let Some(node) = doc.node_mut(page) + && let NodeKind::Page { flows: page_flows, .. } = &mut node.kind + { + page_flows.extend(read); + } + } +} + +/// Collect readable, bounded layout compatibility warnings after importing every page. +/// Per-layer source properties survive a save/open cycle in `plugin_data`, even where +/// FigCraft's current layout engine does not expose the same feature. +fn report_unsupported_layout(doc: &Document, warnings: &mut Vec) { + for (flag, explanation) in [("penpot.unsupported.alignSelf", "unrecognized per-element alignment")] { + let mut count = 0usize; + let mut examples = Vec::new(); + for node in doc.nodes() { + if node.plugin_data.contains_key(flag) { + count += 1; + if examples.len() < 3 { + examples.push(format!("'{}'", node.name.chars().filter(|c| !c.is_control()).take(80).collect::())); + } + } + } + if count > 0 { + warn( + warnings, + format!( + "Penpot: {count} layer(s) use unsupported {explanation}; original values are preserved in layer metadata, but may change visually after auto-layout edits. Example(s): {}", + examples.join(", "), + ), + ); + } + } +} + +/// Link fully mapped local Penpot component copies to FigCraft's native component graph. +/// +/// A Penpot instance may have detached, reordered or injected layers. Syncing one of those as a +/// FigCraft instance would *delete content*. We therefore link only exact shapeRef trees, and +/// leave every other copy independent with its original metadata intact. +fn link_local_instances(doc: &mut Document, warnings: &mut Vec) { + let mut masters = BTreeMap::new(); + for node in doc.nodes() { + if !matches!(node.kind, NodeKind::Component { .. }) { + continue; + } + if let (Some(file), Some(component)) = (node.plugin_data.get("penpot.fileId"), node.plugin_data.get("penpot.componentMasterId")) { + masters.insert((file.clone(), component.clone()), node.id); + } + } + let by_penpot_id: BTreeMap = doc.nodes().filter_map(|n| Some((n.plugin_data.get("penpot.id")?.clone(), n.id))).collect(); + let mut candidates: Vec<(bool, usize, NodeId)> = doc + .nodes() + .filter(|n| n.plugin_data.contains_key("penpot.componentLinkStatus")) + .map(|n| { + let ancestors = doc.ancestors(n.id); + let in_main = ancestors.iter().any(|a| doc.node(*a).is_some_and(|a| matches!(a.kind, NodeKind::Component { .. }))); + (!in_main, if in_main { usize::MAX - ancestors.len() } else { ancestors.len() }, n.id) + }) + .collect(); + candidates.sort(); + let candidates: Vec = candidates.into_iter().map(|(_, _, id)| id).collect(); + let mut missing_masters = false; + let mut editor_state_from_main = 0; + for id in candidates { + let Some(n) = doc.node(id) else { continue }; + // Copies nested in another copy are dealt with separately. Do not turn their root into + // an instance until the surrounding master/instance graph can be safely synchronized. + if doc.ancestors(id).iter().any(|a| doc.node(*a).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))) { + continue; + } + let Some(file) = n.plugin_data.get("penpot.componentFile").or_else(|| n.plugin_data.get("penpot.fileId")) else { continue }; + let Some(component_id) = n.plugin_data.get("penpot.componentId") else { continue }; + let Some(&main) = masters.get(&(file.clone(), component_id.clone())) else { + missing_masters = true; + continue; + }; + if doc.is_ancestor(main, id) || doc.is_ancestor(id, main) { + continue; + } + let mut pairs = Vec::new(); + if let Err(reason) = match_instance_tree(doc, &by_penpot_id, id, main, true, &mut pairs, 0) { + warn(warnings, format!("Component copy '{}' kept independent: {reason}", n.name)); + if let Some(node) = doc.node_mut(id) { + node.plugin_data.insert("penpot.componentLinkReason".into(), reason); + } + continue; + } + editor_state_from_main += pairs + .iter() + .filter(|(copy, source, _)| { + *copy != id + && doc + .node(*copy) + .zip(doc.node(*source)) + .is_some_and(|(c, s)| c.locked != s.locked || c.constrain_proportions != s.constrain_proportions) + }) + .count(); + let nested_kind: BTreeMap = pairs + .iter() + .filter(|(copy, _, _)| *copy != id) + .filter_map(|(copy, source, _)| match doc.node(*source)?.kind.clone() { + kind @ NodeKind::Instance { .. } => Some((*copy, kind)), + _ => None, + }) + .collect(); + for (copy, source, overrides) in pairs { + if let Some(node) = doc.node_mut(copy) { + node.source = Some(source); + node.overrides = overrides; + if copy == id { + node.kind = NodeKind::Instance { component: main, props: BTreeMap::new() }; + node.plugin_data.insert("penpot.componentLinkStatus".into(), "linked".into()); + } else if let Some(kind) = nested_kind.get(©) { + node.kind = kind.clone(); + node.plugin_data.insert("penpot.componentLinkStatus".into(), "linked".into()); + } else if node.plugin_data.contains_key("penpot.componentLinkStatus") { + node.plugin_data.insert("penpot.componentLinkStatus".into(), "layer-of-linked-instance".into()); + } + } + } + } + if missing_masters { + warn(warnings, "Some Penpot component copies could not find their local masters; kept as editable independent frames".into()); + } + if editor_state_from_main > 0 { + warn( + warnings, + format!( + "{editor_state_from_main} layer(s) inside linked component copies now take their lock and proportion-lock state from the main component" + ), + ); + } +} + +/// Preflight an instance and its descendants. The order of children must match exactly because +/// FigCraft's synchronization follows the master's order. Mapping by name alone is unsafe. +fn refers_to(doc: &Document, by_penpot_id: &BTreeMap, copy: &figcraft_doc::Node, source: &figcraft_doc::Node) -> bool { + let Some(target) = source.plugin_data.get("penpot.id") else { return false }; + let mut reference = copy.plugin_data.get("penpot.shapeRef"); + for _ in 0..MAX_DEPTH { + let Some(r) = reference else { return false }; + if r == target { + return true; + } + reference = by_penpot_id.get(r).and_then(|id| doc.node(*id)).and_then(|n| n.plugin_data.get("penpot.shapeRef")); + } + false +} + +/// Compare JSON values while tolerating small numeric differences. +fn nearly_equal(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() <= 0.01, + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(x, y)| nearly_equal(x, y)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| nearly_equal(v, w))), + _ => a == b, + } +} + +/// Check whether two layer kinds have compatible geometric data. +fn same_geometry(a: &NodeKind, b: &NodeKind) -> bool { + match (serde_json::to_value(a), serde_json::to_value(b)) { + (Ok(a), Ok(b)) => nearly_equal(&a, &b), + _ => false, + } +} + +/// Validate corresponding instance descendants before committing their links. +fn match_instance_tree( + doc: &Document, + by_penpot_id: &BTreeMap, + copy_id: NodeId, + source_id: NodeId, + is_root: bool, + pairs: &mut Vec<(NodeId, NodeId, BTreeSet)>, + depth: usize, +) -> Result<(), String> { + if depth >= MAX_DEPTH { + return Err("nesting too deep".into()); + } + let (Some(copy), Some(source)) = (doc.node(copy_id), doc.node(source_id)) else { return Err("missing layer".into()) }; + let at = |what: &str| format!("{what} at '{}'", copy.name.chars().take(60).collect::()); + if !refers_to(doc, by_penpot_id, copy, source) { + return Err(at("layer does not reference its main counterpart")); + } + let kinds_match = if is_root { + matches!((©.kind, &source.kind), (NodeKind::Frame, NodeKind::Component { .. })) + } else { + // Paths, booleans and component sets must be byte-for-byte equivalent: their geometry + // cannot be carried by FigCraft's generic instance override groups. + match (©.kind, &source.kind) { + (NodeKind::Text(_), NodeKind::Text(_)) => true, + (NodeKind::Frame, NodeKind::Instance { .. }) => true, + (a, b) => same_geometry(a, b) && !matches!(a, NodeKind::Component { .. } | NodeKind::Instance { .. } | NodeKind::ComponentSet { .. }), + } + }; + if !kinds_match { + return Err(at("layer type differs from main")); + } + if copy.children.len() != source.children.len() { + return Err(at("children added or removed")); + } + // Nested Penpot component instances have their own source/override graph. + if !is_root + && matches!(source.kind, NodeKind::Instance { .. }) + && copy.plugin_data.get("penpot.componentId") != source.plugin_data.get("penpot.componentId") + { + return Err(at("swapped nested component instance")); + } + // These fields have no reliable component override in FigCraft yet. Any difference must + // keep the copy independent so a later component sync cannot silently erase it. + let blocks = |differs: bool| differs && !is_root; + if copy.clip != source.clip + || blocks(copy.is_mask != source.is_mask) + || copy.layout != source.layout + || blocks(copy.constraints != source.constraints) + || blocks(copy.child_layout.absolute != source.child_layout.absolute) + || copy.grids != source.grids + || copy.overflow != source.overflow + || copy.mask_type != source.mask_type + || blocks(copy.guides != source.guides) + || blocks(copy.exports != source.exports) + || blocks(copy.interactions != source.interactions) + || copy.styles != source.styles + || copy.bound != source.bound + || blocks(copy.var_modes != source.var_modes) + { + return Err(at(&format!("unsupported override ({})", differing_fields(copy, source).join(", ")))); + } + let mut overrides = BTreeSet::new(); + if copy.name != source.name { + overrides.insert("name".into()); + } + if copy.fills != source.fills { + overrides.insert("fills".into()); + } + if copy.strokes != source.strokes || copy.stroke != source.stroke { + overrides.insert("strokes".into()); + } + if copy.effects != source.effects { + overrides.insert("effects".into()); + } + if copy.opacity != source.opacity || copy.blend != source.blend { + overrides.insert("opacity".into()); + } + if copy.visible != source.visible { + overrides.insert("visible".into()); + } + if copy.corners != source.corners { + overrides.insert("corners".into()); + } + if copy.width != source.width || copy.height != source.height || copy.child_layout != source.child_layout { + overrides.insert("size".into()); + } + if !is_root && copy.transform != source.transform { + overrides.insert("position".into()); + } + if let (NodeKind::Text(a), NodeKind::Text(b)) = (©.kind, &source.kind) { + if a.characters != b.characters || a.runs != b.runs || a.paragraphs != b.paragraphs { + overrides.insert("characters".into()); + } + if a.style != b.style + || a.auto_resize != b.auto_resize + || a.align_h != b.align_h + || a.align_v != b.align_v + || a.paragraph_spacing != b.paragraph_spacing + || a.paragraph_indent != b.paragraph_indent + || a.list != b.list + || a.list_spacing != b.list_spacing + || a.hanging_punctuation != b.hanging_punctuation + || a.hanging_list != b.hanging_list + || a.max_lines != b.max_lines + || a.features != b.features + || a.leading_trim != b.leading_trim + || a.path != b.path + { + overrides.insert("textStyle".into()); + } + } + pairs.push((copy_id, source_id, overrides)); + for (&child, &orig) in copy.children.iter().zip(&source.children) { + match_instance_tree(doc, by_penpot_id, child, orig, false, pairs, depth + 1)?; + } + Ok(()) +} + +/// Identify native property groups overridden by an imported instance. +fn differing_fields(copy: &figcraft_doc::Node, source: &figcraft_doc::Node) -> Vec<&'static str> { + [ + ("clip", copy.clip != source.clip), + ("mask", copy.is_mask != source.is_mask), + ("layout", copy.layout != source.layout), + ("constraints", copy.constraints != source.constraints), + ("absolute", copy.child_layout.absolute != source.child_layout.absolute), + ("grids", copy.grids != source.grids), + ("overflow", copy.overflow != source.overflow), + ("mask type", copy.mask_type != source.mask_type), + ("guides", copy.guides != source.guides), + ("exports", copy.exports != source.exports), + ("interactions", copy.interactions != source.interactions), + ("styles", copy.styles != source.styles), + ("variable bindings", copy.bound != source.bound), + ("variable modes", copy.var_modes != source.var_modes), + ] + .into_iter() + .filter(|(_, differs)| *differs) + .map(|(name, _)| name) + .collect() +} + +/// Return a compact, inspectable snapshot of unsupported Penpot geometry/properties. Keeping +/// the source data makes later import upgrades and manual reconstruction possible after saving. +fn preserve_source(node: &mut figcraft_doc::Node, v: &Value, keys: &[&str]) { + preserve_source_within(node, v, keys, 16_384); +} + +/// Retain selected source fields without exceeding the metadata size budget. +fn preserve_source_within(node: &mut figcraft_doc::Node, v: &Value, keys: &[&str], limit: usize) { + for &key in keys { + if let Some(value) = v.get(key) { + // Never fill a document with arbitrary megabytes of opaque source JSON. + if let Ok(encoded) = serde_json::to_string(value) + && encoded.len() <= limit + { + node.plugin_data.insert(format!("penpot.source.{key}"), encoded); + } + } + } +} + +/// Translate Penpot sizing names to fixed, fill or hug sizing. +fn penpot_sizing(value: Option<&str>) -> Sizing { + match value { + Some("auto" | "hug") => Sizing::Hug, + Some("fill") => Sizing::Fill, + _ => Sizing::Fixed, + } +} + +/// Translate main-axis alignment, defaulting unknown values to the start. +fn align_main(v: Option<&str>) -> MainAlign { + match v { + Some("center") => MainAlign::Center, + Some("end") => MainAlign::Max, + Some("space-between") => MainAlign::SpaceBetween, + Some("space-around") => MainAlign::SpaceAround, + Some("space-evenly") => MainAlign::SpaceEvenly, + _ => MainAlign::Min, + } +} + +/// Translate cross-axis alignment, including stretch and baseline. +fn align_cross(v: Option<&str>) -> CrossAlign { + match v { + Some("center") => CrossAlign::Center, + Some("end") => CrossAlign::Max, + _ => CrossAlign::Min, + } +} + +/// Read nonnegative padding supported by the current model. +fn padding(v: &Value) -> [f64; 4] { + let Some(p) = v.get("layoutPadding") else { return [0.0; 4] }; + [number(p, "p1", 0.0), number(p, "p2", 0.0), number(p, "p3", 0.0), number(p, "p4", 0.0)].map(|n| n.clamp(0.0, 100_000.0)) +} + +/// Translate a supported Penpot track definition to native grid sizing. +fn grid_track(v: &Value) -> Option { + match string(v, "type")? { + "fixed" => Some(GridTrack::fixed(number(v, "value", 0.0).max(0.0))), + "auto" => Some(GridTrack::hug()), + "flex" => Some(GridTrack::fill(number(v, "value", 1.0).max(0.001))), + _ => None, + } +} + +/// Map Penpot layout fields to the FigCraft frame layout engine. Unsupported directions / custom +/// grid track types are intentionally left as absolute-positioned frames (not approximated). +fn layout_props(v: &Value, warnings: &mut Vec) -> Option { + let mode = string(v, "layout").or_else(|| string(v, "layoutMode"))?; + let unsupported = if matches!(string(v, "layoutFlexDir"), Some("row-reverse" | "column-reverse")) { + Some("reverse flow") + } else if string(v, "layoutAlignItems") == Some("stretch") { + Some("stretch alignment") + } else if string(v, "layoutWrapType") == Some("wrap") && matches!(string(v, "layoutAlignContent"), Some("space-around" | "space-evenly")) { + Some("wrapped-line distribution") + } else if v.get("layoutPadding").is_some_and(|p| ["p1", "p2", "p3", "p4"].iter().any(|k| number(p, k, 0.0) < 0.0)) { + Some("negative padding") + } else if list(v, "layoutGridColumns").chain(list(v, "layoutGridRows")).any(|t| string(t, "type") == Some("percent")) { + Some("percentage tracks") + } else { + None + }; + if let Some(reason) = unsupported { + warn(warnings, format!("Penpot {reason} is unsupported; saved positions and source metadata were kept")); + return None; + } + let mut al = AutoLayout { padding: padding(v), strokes_included: false, ..AutoLayout::default() }; + let gap = v.get("layoutGap"); + let row_gap = gap.map(|v| number(v, "rowGap", 0.0)).unwrap_or(0.0).clamp(0.0, 100_000.0); + let column_gap = gap.map(|v| number(v, "columnGap", 0.0)).unwrap_or(0.0).clamp(0.0, 100_000.0); + al.main_align = align_main(string(v, "layoutJustifyContent")); + al.cross_align = align_cross(string(v, "layoutAlignItems")); + match mode { + "flex" => { + al.mode = match string(v, "layoutFlexDir") { + Some("column") | Some("column-reverse") => LayoutMode::Vertical, + Some("row") | Some("row-reverse") | None => LayoutMode::Horizontal, + _ => { + warn(warnings, "Unknown Penpot flex direction retained at absolute positions".into()); + return None; + } + }; + al.gap = if al.mode == LayoutMode::Horizontal { column_gap } else { row_gap }; + al.cross_gap = if al.mode == LayoutMode::Horizontal { row_gap } else { column_gap }; + al.wrap = matches!(string(v, "layoutWrapType"), Some("wrap")); + if al.wrap { + al.content_align = match string(v, "layoutAlignContent") { + Some("space-between") => ContentAlign::SpaceBetween, + _ => ContentAlign::Auto, + }; + } + } + "grid" => { + let cols = list(v, "layoutGridColumns").collect::>(); + let rows = list(v, "layoutGridRows").collect::>(); + if cols.is_empty() || rows.is_empty() || cols.len() > 256 || rows.len() > 256 { + warn(warnings, "Unsupported Penpot grid dimensions; positions kept".into()); + return None; + } + let Some(column_tracks) = cols.iter().map(|v| grid_track(v)).collect::>>() else { + warn(warnings, "Unsupported Penpot grid column tracks; positions kept".into()); + return None; + }; + let Some(row_tracks) = rows.iter().map(|v| grid_track(v)).collect::>>() else { + warn(warnings, "Unsupported Penpot grid row tracks; positions kept".into()); + return None; + }; + al.mode = LayoutMode::Grid; + al.main_align = align_main(string(v, "layoutJustifyItems")); + al.grid_columns = column_tracks.len() as u32; + al.grid_rows = row_tracks.len() as u32; + al.column_tracks = column_tracks; + al.row_tracks = row_tracks; + al.gap = column_gap; + al.cross_gap = row_gap; + } + _ => { + warn(warnings, format!("Unsupported Penpot layout '{mode}' retained at absolute positions")); + return None; + } + } + Some(al) +} + +/// Penpot grid-cell indices are one-based; FigCraft uses zero-based indices. The `shapes` +/// memberships are the authoritative mapping (not a cell's UUID). +fn grid_cells(parent: &Value) -> BTreeMap<&str, &Value> { + let mut out = BTreeMap::new(); + for cell in parent.get("layoutGridCells").and_then(Value::as_object).into_iter().flat_map(|m| m.values()) { + for id in list(cell, "shapes").filter_map(Value::as_str) { + out.entry(id).or_insert(cell); + } + } + out +} + +/// Map explicit grid-cell alignment; absent values inherit the grid setting. +fn align_cell(v: Option<&str>) -> Option { + match v { + Some("start") => Some(CellAlign::Min), + Some("center") => Some(CellAlign::Center), + Some("end") => Some(CellAlign::Max), + _ => None, + } +} + +/// Apply sizing, margins, alignment and one-based grid-cell placement to a layer. +fn apply_child_layout(v: &Value, cell: Option<&Value>, node: &mut figcraft_doc::Node) { + node.child_layout.sizing_h = penpot_sizing(string(v, "layoutItemHSizing")); + node.child_layout.sizing_v = penpot_sizing(string(v, "layoutItemVSizing")); + node.child_layout.absolute = v.get("layoutItemAbsolute").and_then(Value::as_bool) == Some(true); + if v.get("layoutItemMargin").is_some_and(|m| ["m1", "m2", "m3", "m4"].iter().any(|k| number(m, k, 0.0) != 0.0)) + || string(v, "layoutItemAlignSelf").is_some_and(|a| a != "auto") + { + node.plugin_data.insert("penpot.unsupported.itemLayout".into(), "true".into()); + } + for (key, target) in [ + ("layoutItemMinW", &mut node.child_layout.min_w), + ("layoutItemMaxW", &mut node.child_layout.max_w), + ("layoutItemMinH", &mut node.child_layout.min_h), + ("layoutItemMaxH", &mut node.child_layout.max_h), + ] { + if v.get(key).is_some() { + *target = Some(number(v, key, 0.0).max(0.0)); + } + } + if let Some(cell) = cell { + // A manually positioned cell is not the same as an absolutely positioned child: + // it remains in grid flow but has a fixed grid slot. + node.child_layout.grid_column = cell.get("column").and_then(Value::as_u64).and_then(|n| n.checked_sub(1)).and_then(|n| u32::try_from(n).ok()); + node.child_layout.grid_row = cell.get("row").and_then(Value::as_u64).and_then(|n| n.checked_sub(1)).and_then(|n| u32::try_from(n).ok()); + node.child_layout.grid_column_span = cell.get("columnSpan").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(1).max(1); + node.child_layout.grid_row_span = cell.get("rowSpan").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(1).max(1); + node.child_layout.grid_align_h = align_cell(string(cell, "justifySelf")); + node.child_layout.grid_align_v = align_cell(string(cell, "alignSelf")); + } +} + +/// Bring Penpot library color swatches into FigCraft's editable document palette. +/// Bind only the narrow subset of Penpot `appliedTokens` that FigCraft can represent +/// without changing the initial appearance: single solid fill/stroke colors and fixed +/// layer width/height. Penpot stores token names without their set; if two sets contain +/// the same name, choosing one would silently change the design when modes change. +/// Preserve *all* original bindings in `penpot.source.appliedTokens` for future importers. +const NUMBER_TOKEN_PROPERTIES: &[&str] = + &["columnGap", "rowGap", "p1", "p2", "p3", "p4", "r1", "r2", "r3", "r4", "strokeWidth", "fontSize", "letterSpacing"]; + +/// Find a bindable native field only when its current value matches the token. +fn token_number_field(node: &figcraft_doc::Node, property: &str, value: f64) -> Option<&'static str> { + let same = |current: f64| value.is_finite() && (current - value).abs() < 0.0001; + let layout = node.layout.as_ref(); + let vertical = layout.is_some_and(|l| l.mode == LayoutMode::Vertical); + let gap = |main: bool| { + layout.and_then(|l| { + let (field, current) = if main { ("gap", l.gap) } else { ("crossGap", l.cross_gap) }; + same(current).then_some(field) + }) + }; + let padding = |i: usize, field| layout.and_then(|l| l.padding.get(i).copied()).filter(|p| same(*p)).map(|_| field); + let radius = |i: usize, field| node.corners.radii.get(i).copied().filter(|r| same(*r)).map(|_| field); + let text = match &node.kind { + NodeKind::Text(t) => Some(&t.style), + _ => None, + }; + match property { + "columnGap" => gap(!vertical), + "rowGap" => gap(vertical), + "p1" => padding(0, "paddingTop"), + "p2" => padding(1, "paddingRight"), + "p3" => padding(2, "paddingBottom"), + "p4" => padding(3, "paddingLeft"), + "r1" => radius(0, "topLeftRadius"), + "r2" => radius(1, "topRightRadius"), + "r3" => radius(2, "bottomRightRadius"), + "r4" => radius(3, "bottomLeftRadius"), + "strokeWidth" => (!node.strokes.is_empty() && same(node.stroke.weight)).then_some("strokeWeight"), + "fontSize" => text.filter(|t| same(t.font_size)).map(|_| "fontSize"), + "letterSpacing" => text.filter(|t| matches!(t.letter_spacing, LetterSpacing::Pixels(px) if same(px))).map(|_| "letterSpacing"), + _ => None, + } +} + +/// Bind supported token references while retaining unresolved source metadata. +fn bind_applied_tokens(doc: &mut Document, active_modes: &BTreeMap, warnings: &mut Vec) { + // Index names within the original Penpot file. Multiple sets may export tokens + // with identical names; keep all candidates rather than silently selecting one. + let mut index: BTreeMap<(String, String), Vec> = BTreeMap::new(); + for var in &doc.variables.variables { + let Some(raw) = var.id.strip_prefix("penpot:tokens:files/") else { continue }; + let Some((file_id, _)) = raw.split_once(':') else { continue }; + let Some((_, short_name)) = var.name.split_once('/') else { continue }; + for token_name in [short_name, var.name.as_str()] { + index.entry((file_id.to_owned(), token_name.to_owned())).or_default().push(var.id.clone()); + } + } + if index.is_empty() { + return; + } + + let mut updates = Vec::new(); + let mut unlinked_by_node = Vec::new(); + let mut linked = 0usize; + let mut rejected = 0usize; + let mut affected = BTreeSet::new(); + // Work from immutable snapshots first to avoid mixing node mutation with + // variable resolution and to keep this pass deterministic. + for node in doc.nodes() { + let (Some(file), Some(raw)) = (node.plugin_data.get("penpot.fileId"), node.plugin_data.get("penpot.source.appliedTokens")) else { continue }; + let Ok(Value::Object(tokens)) = serde_json::from_str::(raw) else { + rejected += 1; + affected.insert(node.name.clone()); + continue; + }; + let mut bindings = BTreeMap::new(); + let mut unlinked = BTreeMap::new(); + for (property, token) in &tokens { + let outcome = (|| { + let raw_name = token.as_str().ok_or("not a token reference")?; + let name = raw_name.strip_prefix('{').and_then(|x| x.strip_suffix('}')).unwrap_or(raw_name); + if name.is_empty() || name.len() > 256 { + return Err("invalid token name"); + } + let ids = index.get(&(file.clone(), name.to_owned())).ok_or("token not imported")?; + let [id] = ids.as_slice() else { return Err("ambiguous token name") }; + let value = doc.variables.resolve(id, active_modes).ok_or("token does not resolve")?; + let field = match (property.as_str(), value) { + ("fill", VariableValue::Color(color)) + if node.fills.len() == 1 + && node.fills.first().and_then(Paint::solid_color).is_some_and(|c| c.to_rgba8() == color.to_rgba8()) => + { + "fills/0/color" + } + ("strokeColor", VariableValue::Color(color)) + if node.strokes.len() == 1 + && node.strokes.first().and_then(Paint::solid_color).is_some_and(|c| c.to_rgba8() == color.to_rgba8()) => + { + "strokes/0/color" + } + ("fill" | "strokeColor", _) => return Err("layer color differs from the token"), + ("width", VariableValue::Number(value)) if value.is_finite() && (node.width - value).abs() < 0.0001 => "width", + ("height", VariableValue::Number(value)) if value.is_finite() && (node.height - value).abs() < 0.0001 => "height", + (property, VariableValue::Number(value)) => { + token_number_field(node, property, value).ok_or(if NUMBER_TOKEN_PROPERTIES.contains(&property) { + "layer value differs from the token" + } else { + "property has no FigCraft binding" + })? + } + ("fontFamily", VariableValue::String(family)) if matches!(&node.kind, NodeKind::Text(t) if t.style.font_family == family) => { + "fontFamily" + } + _ => return Err("property has no FigCraft binding"), + }; + Ok((field, id.clone())) + })(); + match outcome { + Ok((field, id)) => { + bindings.insert(field.to_owned(), id); + linked += 1; + } + Err(reason) => { + unlinked.insert(property.clone(), Value::from(reason)); + rejected += 1; + affected.insert(node.name.clone()); + } + } + } + if !unlinked.is_empty() + && let Ok(json) = serde_json::to_string(&unlinked) + { + unlinked_by_node.push((node.id, json)); + } + if !bindings.is_empty() { + updates.push((node.id, bindings)); + } + } + for (node_id, bindings) in updates { + if let Some(node) = doc.node_mut(node_id) { + node.bound.extend(bindings); + } + } + for (node_id, json) in unlinked_by_node { + if let Some(node) = doc.node_mut(node_id) { + node.plugin_data.insert("penpot.unlinkedTokens".into(), json); + } + } + if linked > 0 { + warn( + warnings, + format!( + "Linked {linked} Penpot token reference(s) to native FigCraft properties (colors, sizes, gaps, padding, radii, stroke widths, font sizes and families)" + ), + ); + } + if rejected > 0 { + let examples: Vec = affected.into_iter().take(4).map(|s| s.chars().take(60).collect()).collect(); + warn( + warnings, + format!( + "{rejected} Penpot token reference(s) could not be safely linked (unsupported, ambiguous or different resolved value); original references retained on layers{}", + if examples.is_empty() { String::new() } else { format!(" including {}", examples.join(", ")) } + ), + ); + } +} + +/// Import Penpot library colors as reusable native styles. Original design-token +/// references are handled separately by `bind_applied_tokens` after shape import. +fn import_document_colors(z: &mut ZipArchive>, base: &str, doc: &mut Document, warnings: &mut Vec, budget: &mut InflateBudget) { + let prefix = format!("{base}/colors/"); + let paths = z + .file_names() + .filter(|name| name.strip_prefix(&prefix).is_some_and(|part| part.len() == 41 && part.ends_with(".json") && part.get(..36).is_some_and(uuid))) + .take(2049) + .map(str::to_owned) + .collect::>(); + if paths.len() > 2048 { + warn(warnings, "Penpot color library exceeds 2048 swatches; truncated".into()); + } + for path in paths.into_iter().take(2048) { + if let Ok(value) = json(z, &path, budget) { + if let Some(color) = string(&value, "color").and_then(Color::from_hex) { + let color = color.with_alpha((color.a as f64 * number(&value, "opacity", 1.0).clamp(0.0, 1.0)) as f32); + if !doc.document_colors.contains(&color) { + doc.document_colors.push(color); + } + if doc.styles.len() < 8192 + && let (Some(id), Some(name)) = (string(&value, "id").filter(|s| uuid(s)), string(&value, "name")) + { + let full_name = match string(&value, "path").filter(|s| !s.is_empty()) { + Some(path) => format!("{path}/{name}"), + None => name.to_owned(), + }; + doc.styles.push(Style { + id: format!("penpot:color:{base}:{id}"), + name: full_name.chars().take(256).collect(), + description: "Imported from Penpot color library".into(), + kind: StyleKind::Paint { paints: vec![Paint::solid(color)] }, + bound: BTreeMap::new(), + }); + } + } else if value.get("gradient").is_some() || value.get("image").is_some() { + warn(warnings, "Gradient/image library swatches kept on imported shapes; not added to the solid color palette".into()); + } + } + } +} + +/// Import named Penpot typography assets as native reusable FigCraft text styles. +/// Keep original values on the text nodes: attaching a shared style automatically +/// would override local formatting unless the source explicitly links it. +fn import_typography_styles( + z: &mut ZipArchive>, + base: &str, + doc: &mut Document, + warnings: &mut Vec, + budget: &mut InflateBudget, +) -> Vec { + let mut custom_refs = Vec::new(); + let prefix = format!("{base}/typographies/"); + let paths: Vec = z + .file_names() + .filter(|name| name.strip_prefix(&prefix).is_some_and(|part| part.len() == 41 && part.ends_with(".json") && part.get(..36).is_some_and(uuid))) + .take(2049) + .map(str::to_owned) + .collect(); + if paths.len() > 2048 { + warn(warnings, "Penpot typography library exceeds 2048 entries; truncated".into()); + } + for path in paths.into_iter().take(2048) { + if doc.styles.len() >= 8192 { + warn(warnings, "Imported Penpot styles capped at 8192 entries".into()); + break; + } + let Ok(value) = json(z, &path, budget) else { + warn(warnings, "A Penpot typography definition could not be decoded".into()); + continue; + }; + let Some(id) = string(&value, "id").filter(|s| uuid(s)) else { + continue; + }; + let Some(name) = string(&value, "name").filter(|s| !s.is_empty()) else { + continue; + }; + let name = match string(&value, "path").filter(|s| !s.is_empty()) { + Some(path) => format!("{path}/{name}"), + None => name.to_owned(), + }; + // Typography-library assets can reference a Penpot-managed font that + // has no matching text run yet. Retain that reference on the page for + // diagnosing missing/custom families without claiming to own its bytes. + for reference in referenced_custom_fonts(&value) { + if custom_refs.len() < 128 && !custom_refs.contains(&reference) { + custom_refs.push(reference); + } + } + let style = text_style(&value, &TextStyle::default()); + doc.styles.push(Style { + id: format!("penpot:text:{base}:{id}"), + name: name.chars().take(256).collect(), + description: "Imported from Penpot typography library".into(), + kind: StyleKind::Text { style }, + bound: BTreeMap::new(), + }); + } + custom_refs +} + +/// DTCG typography and shadow objects are not scalar FigCraft variables. Instead, bring +/// simple literal compound values across as reusable native styles. In particular, do +/// not evaluate token aliases here: active themes may map those to different values. +fn compound_number(value: &Value) -> Option { + let n = + value.as_f64().or_else(|| value.as_str()?.trim().strip_suffix("px").or_else(|| value.as_str()).and_then(|s| s.trim().parse::().ok()))?; + (n.is_finite() && n.abs() <= 100_000.0).then_some(n) +} + +/// Convert literal typography or shadow token values to a reusable native style. +fn composite_token_style(token: &Value) -> Option { + let kind = string(token, "type").or_else(|| string(token, "$type"))?; + let value = token.get("value").or_else(|| token.get("$value"))?; + match kind { + "typography" => { + let props = value.as_object()?; + if props.is_empty() || (!props.contains_key("fontFamily") && !props.contains_key("fontSize")) { + return None; + } + let mut style = TextStyle::default(); + let mut fields = serde_json::Map::new(); + if let Some(family) = value.get("fontFamily").and_then(Value::as_str) { + if family.contains('{') || family.len() > 200 { + return None; + } + fields.insert("fontFamily".into(), Value::String(family.into())); + } + if let Some(size) = value.get("fontSize").and_then(compound_number) { + if size <= 0.0 || size > 1000.0 { + return None; + } + fields.insert("fontSize".into(), serde_json::json!(size)); + } + if let Some(weight) = value.get("fontWeight") { + if weight.as_str().is_some_and(|s| s.contains('{')) { + return None; + } + fields.insert("fontWeight".into(), Value::String(weight.as_str().map(str::to_owned).unwrap_or_else(|| weight.to_string()))); + } + if let Some(spacing_value) = value.get("letterSpacing") { + fields.insert("letterSpacing".into(), serde_json::json!(compound_number(spacing_value)?)); + } + if let Some(raw_line) = value.get("lineHeight") { + let line = compound_number(raw_line)?; + if line > 0.0 { + // DTCG line height may be a unitless multiplier or a pixel height. + // Do not guess: explicit pixel lengths are native pixels; an + // unadorned small numeric value is a font-size multiplier. + if value.get("lineHeight").and_then(Value::as_str).is_some_and(|s| s.trim().ends_with("px")) { + style.line_height = LineHeight::Pixels(line); + } else if line <= 10.0 { + fields.insert("lineHeight".into(), serde_json::json!(line)); + } else { + return None; + } + } + } + let parsed = text_style(&Value::Object(fields), &style); + Some(StyleKind::Text { style: parsed }) + } + "boxShadow" | "shadow" => { + let parts: Vec<&Value> = if let Some(items) = value.as_array() { items.iter().take(32).collect() } else { vec![value] }; + if parts.is_empty() { + return None; + } + let mut effects = Vec::new(); + for part in parts { + let raw = part.as_object()?; + let color = token_color(raw.get("color")?.as_str()?)?; + let x = raw.get("x").or_else(|| raw.get("offsetX")).and_then(compound_number)?; + let y = raw.get("y").or_else(|| raw.get("offsetY")).and_then(compound_number)?; + let blur = raw.get("blur").and_then(compound_number)?; + let spread = match raw.get("spread") { + Some(value) => compound_number(value)?, + None => 0.0, + }; + if blur < 0.0 { + return None; + } + let shadow = Shadow { color, offset: (x, y), radius: blur, spread, ..Shadow::default() }; + let inner = matches!(string(part, "type"), Some("innerShadow" | "inner" | "inset")); + effects.push(Effect { kind: if inner { EffectKind::InnerShadow(shadow) } else { EffectKind::DropShadow(shadow) }, visible: true }); + } + Some(StyleKind::Effect { effects }) + } + _ => None, + } +} + +/// Import compound token styles without guessing unresolved aliases. +fn import_composite_token_styles(source: &Value, base: &str, doc: &mut Document, warnings: &mut Vec) { + let Some(sets) = source.get("sets").and_then(Value::as_object) else { return }; + let mut count = 0; + let mut unsupported = 0; + for (set_name, definition) in sets.iter().take(256) { + let Some(tokens) = definition.get("tokens") else { continue }; + let mut flattened = Vec::new(); + flatten_tokens(tokens, "", &mut flattened, 0); + for (name, token) in flattened { + let kind = string(token, "$type").or_else(|| string(token, "type")); + if !matches!(kind, Some("typography" | "boxShadow" | "shadow")) { + continue; + } + if doc.styles.len() >= 8192 { + unsupported += 1; + continue; + } + if let Some(kind) = composite_token_style(token) { + doc.styles.push(Style { + id: format!("penpot:token-style:{base}:{set_name}:{name}"), + name: format!("Penpot tokens/{set_name}/{name}").chars().take(256).collect(), + description: "Imported from a literal compound Penpot token; theme aliases remain source metadata".into(), + kind, + bound: BTreeMap::new(), + }); + count += 1; + } else { + unsupported += 1; + } + } + } + if count > 0 { + warn(warnings, format!("Imported {count} literal Penpot typography/shadow tokens as reusable styles")); + } + if unsupported > 0 { + warn(warnings, format!("{unsupported} compound tokens could not be converted safely; source JSON preserved when possible")); + } +} + +/// Convert scalar DTCG/Penpot tokens to editable native variables. Compound +/// typography, shadow, dimension expressions and unresolved aliases are deliberately +/// not interpreted as literal values; the entire original library is preserved on +/// a page for a later, fuller parser. +fn scalar_token(token: &Value) -> Option<(VariableType, VariableValue)> { + let kind = string(token, "type").or_else(|| string(token, "$type"))?; + let value = token.get("value").or_else(|| token.get("$value"))?; + let text = value.as_str(); + if text.is_some_and(|t| t.starts_with('{') && t.ends_with('}')) { + return None; + } + match kind { + "color" => Some((VariableType::Color, VariableValue::Color(token_color(text?)?))), + "number" | "numeric" | "dimension" | "sizing" | "spacing" | "borderRadius" | "opacity" | "fontSizes" | "fontWeights" | "borderWidth" + | "letterSpacing" | "rotation" | "lineHeights" | "paragraphSpacing" => { + let n = value.as_f64().or_else(|| text.and_then(|t| t.strip_suffix("px").unwrap_or(t).trim().parse::().ok()))?; + if !n.is_finite() || n.abs() > 1_000_000.0 { + return None; + } + Some((VariableType::Number, VariableValue::Number(n))) + } + "string" | "fontFamily" | "fontFamilies" | "fontWeight" | "textCase" | "textDecoration" => { + let text = text?; + if text.len() > 4096 { + return None; + } + Some((VariableType::String, VariableValue::String(text.into()))) + } + "boolean" => Some((VariableType::Boolean, VariableValue::Boolean(value.as_bool()?))), + _ => None, + } +} + +/// Native alias variables must have the same type as their target. Penpot's +/// token type vocabulary is larger than FigCraft's four scalar types; unknown +/// kinds remain source metadata rather than being converted incorrectly. +fn token_color(text: &str) -> Option { + let text = text.trim(); + if text.eq_ignore_ascii_case("transparent") { + return Some(Color::new(0.0, 0.0, 0.0, 0.0)); + } + if let Some(args) = text.strip_prefix("rgba(").or_else(|| text.strip_prefix("rgb(")).and_then(|t| t.strip_suffix(')')) { + let parts: Vec = args + .split([',', ' ', '/']) + .filter(|p| !p.is_empty()) + .map(|p| p.trim().parse::().ok().filter(|n| n.is_finite())) + .collect::>()?; + let channel = |i: usize| parts.get(i).map(|v| (v / 255.0).clamp(0.0, 1.0) as f32); + let alpha = parts.get(3).map_or(1.0, |a| a.clamp(0.0, 1.0) as f32); + return Some(Color::new(channel(0)?, channel(1)?, channel(2)?, alpha)); + } + Color::from_hex(text) +} + +/// Map supported scalar token types to the native variable model. +fn token_kind(token: &Value) -> Option { + match string(token, "type").or_else(|| string(token, "$type"))? { + "color" => Some(VariableType::Color), + "number" | "numeric" | "dimension" | "sizing" | "spacing" | "borderRadius" | "opacity" | "fontSizes" | "fontWeights" | "borderWidth" + | "letterSpacing" | "rotation" | "lineHeights" | "paragraphSpacing" => Some(VariableType::Number), + "string" | "fontFamily" | "fontFamilies" | "fontWeight" | "textCase" | "textDecoration" => Some(VariableType::String), + "boolean" => Some(VariableType::Boolean), + _ => None, + } +} + +/// A Penpot alias is an entire value of the form `{path.to.token}`, not a +/// string containing an interpolated expression. Do not interpret references +/// outside the same token set as a live FigCraft variable without a mode map. +fn token_alias(token: &Value) -> Option<&str> { + let text = token.get("value").or_else(|| token.get("$value"))?.as_str()?; + let path = text.strip_prefix('{')?.strip_suffix('}')?; + (!path.is_empty() && path.len() <= 256 && !path.contains('{') && !path.contains('}')).then_some(path) +} + +/// Resolve the declared type through a same-set alias chain. Guard cycles and +/// excessive depth; all terminal scalar values must parse successfully. +fn resolved_token_kind(name: &str, tokens: &BTreeMap<&str, &Value>, visiting: &mut BTreeSet, depth: usize) -> Option { + if depth >= 32 || !visiting.insert(name.to_owned()) { + return None; + } + let result = (|| { + let token = *tokens.get(name)?; + let kind = token_kind(token)?; + if let Some(next) = token_alias(token) { + (resolved_token_kind(next, tokens, visiting, depth + 1)? == kind).then_some(kind) + } else { + (scalar_token(token)?.0 == kind).then_some(kind) + } + })(); + visiting.remove(name); + result +} + +/// Collect dotted token names within the nesting and token-count budgets. +fn flatten_tokens<'a>(value: &'a Value, path: &str, out: &mut Vec<(String, &'a Value)>, depth: usize) { + if depth >= 16 || out.len() >= 4096 { + return; + } + if value.get("value").is_some() || value.get("$value").is_some() { + if !path.is_empty() { + out.push((path.into(), value)); + } + return; + } + let Some(fields) = value.as_object() else { + return; + }; + for (name, token) in fields { + if out.len() >= 4096 { + break; + } + if name.starts_with('$') || name == "id" || name == "name" || name == "type" { + continue; + } + let full = if path.is_empty() { name.to_string() } else { format!("{path}.{name}") }; + if full.len() <= 256 { + flatten_tokens(token, &full, out, depth + 1); + } + } +} + +/// Each Penpot token set becomes a native collection with a Default mode. Sets +/// and themes can overlap, so we do not pretend that independent set variables +/// are equivalent to multidimensional Penpot theme activation. +/// Total expression work allowed across every token and theme mode in one import. +/// Charging the whole expression at every evaluation bounds repeated alias expansion. +const MAX_TOKEN_RESOLUTION_WORK: usize = 2_000_000; + +struct TokenResolutionBudget { + remaining: usize, +} + +impl TokenResolutionBudget { + fn spend(&mut self, units: usize) -> Option<()> { + match self.remaining.checked_sub(units) { + Some(left) => { + self.remaining = left; + Some(()) + } + None => { + self.remaining = 0; + None + } + } + } +} + +fn token_number( + path: &str, + sets: &[&str], + tokens: &BTreeMap<&str, BTreeMap>, + alias_depth: usize, + expr_depth: usize, + budget: &mut TokenResolutionBudget, +) -> Option { + // Each alias lookup costs work, even when it fails. Do not give aliases a fresh budget. + budget.spend(1)?; + if alias_depth > 32 || expr_depth >= MAX_TOKEN_EXPR_NESTING { + return None; + } + // Every examined set is work, including misses. Charge the actual linear scan, + // rather than one unit for the entire lookup. + let token = sets.iter().rev().find_map(|s| { + budget.spend(1)?; + tokens.get(s)?.get(path) + })?; + let value = token.get("$value").or_else(|| token.get("value"))?; + let number = match value { + Value::Number(n) => n.as_f64(), + Value::String(text) => { + // Parsing a string is linear in its length. Re-parsing an alias costs it again. + budget.spend(text.len())?; + let mut resolve = |r: &str, nesting: usize| token_number(r, sets, tokens, alias_depth + 1, nesting, budget); + TokenExpr { text: text.as_bytes(), at: 0, nesting: expr_depth, resolve: &mut resolve }.parse() + } + _ => None, + }?; + (number.is_finite() && number.abs() <= 1_000_000.0).then_some(number) +} + +/// Maximum call nesting while evaluating untrusted Penpot expressions, including aliases. +const MAX_TOKEN_EXPR_NESTING: usize = 64; + +struct TokenExpr<'a> { + text: &'a [u8], + at: usize, + nesting: usize, + resolve: &'a mut dyn FnMut(&str, usize) -> Option, +} + +impl TokenExpr<'_> { + fn parse(mut self) -> Option { + let value = self.sum()?; + self.skip(); + (self.at == self.text.len()).then_some(value) + } + fn skip(&mut self) { + while self.text.get(self.at).is_some_and(u8::is_ascii_whitespace) { + self.at += 1; + } + } + fn eat(&mut self, c: u8) -> bool { + self.skip(); + let found = self.text.get(self.at) == Some(&c); + if found { + self.at += 1; + } + found + } + fn sum(&mut self) -> Option { + let mut value = self.product()?; + loop { + if self.eat(b'+') { + value += self.product()?; + } else if self.eat(b'-') { + value -= self.product()?; + } else { + return Some(value); + } + } + } + fn product(&mut self) -> Option { + let mut value = self.unary()?; + loop { + if self.eat(b'*') { + value *= self.unary()?; + } else if self.eat(b'/') { + let d = self.unary()?; + value = if d == 0.0 { return None } else { value / d }; + } else if self.eat(b'%') { + let d = self.unary()?; + value = if d == 0.0 { return None } else { value % d }; + } else { + return Some(value); + } + } + } + fn unary(&mut self) -> Option { + if self.nesting >= MAX_TOKEN_EXPR_NESTING { + return None; + } + self.nesting += 1; + let value = if self.eat(b'-') { + self.unary().map(|v| -v) + } else { + self.eat(b'+'); + self.atom() + }; + self.nesting -= 1; + value + } + fn atom(&mut self) -> Option { + self.skip(); + if self.eat(b'(') { + let value = self.sum()?; + return self.eat(b')').then_some(value); + } + if self.eat(b'{') { + let start = self.at; + while self.text.get(self.at).is_some_and(|c| *c != b'}') { + self.at += 1; + } + let name = std::str::from_utf8(self.text.get(start..self.at)?).ok()?.trim().to_owned(); + self.at += 1; + return (self.resolve)(&name, self.nesting); + } + let start = self.at; + while self.text.get(self.at).is_some_and(|c| c.is_ascii_digit() || *c == b'.') { + self.at += 1; + } + if self.at > start { + let number = std::str::from_utf8(self.text.get(start..self.at)?).ok()?.parse::().ok()?; + if self.text.get(self.at..self.at + 2) == Some(b"px") { + self.at += 2; + } + return Some(number); + } + while self.text.get(self.at).is_some_and(u8::is_ascii_alphabetic) { + self.at += 1; + } + let name = std::str::from_utf8(self.text.get(start..self.at)?).ok()?.to_ascii_lowercase(); + if !self.eat(b'(') { + return None; + } + let mut args = vec![self.sum()?]; + while self.eat(b',') { + args.push(self.sum()?); + } + if !self.eat(b')') { + return None; + } + let first = *args.first()?; + Some(match (name.as_str(), args.len()) { + ("round", 1) => first.round(), + ("round" | "roundto", 2) => { + let step = 10f64.powi(args[1] as i32); + (first * step).round() / step + } + ("floor", 1) => first.floor(), + ("ceil", 1) => first.ceil(), + ("abs", 1) => first.abs(), + ("sqrt", 1) if first >= 0.0 => first.sqrt(), + ("log", 1) if first > 0.0 => first.ln(), + ("log", 2) if first > 0.0 && args[1] > 0.0 && args[1] != 1.0 => first.log(args[1]), + ("pow", 2) => first.powf(args[1]), + ("min", _) => args.iter().copied().fold(f64::INFINITY, f64::min), + ("max", _) => args.iter().copied().fold(f64::NEG_INFINITY, f64::max), + _ => return None, + }) + } +} + +type ModePlan<'a> = (String, String, Vec<&'a str>); +type CollectionPlan<'a> = (String, String, Vec>); + +struct TokenTheme<'a> { + id: String, + name: &'a str, + sets: Vec<&'a str>, +} + +/// Look up a path through its existing set memberships, newest set first. +/// Avoid scanning every enabled set for every variable and mode; charge each +/// candidate examined so even deliberately overlapping sets have bounded work. +fn indexed_token<'a>( + path: &str, + selected: &BTreeSet, + index: &'a BTreeMap<&str, Vec<(usize, &'a Value)>>, + budget: &mut TokenResolutionBudget, +) -> Option<&'a Value> { + index.get(path)?.iter().rev().find_map(|(set, value)| { + budget.spend(1)?; + selected.contains(set).then_some(*value) + }) +} + +/// Import token sets and theme groups as variables, collections and modes. +fn import_themed_tokens( + value: &Value, + base: &str, + doc: &mut Document, + active_modes: &mut BTreeMap, + warnings: &mut Vec, +) -> bool { + let Some(root) = value.as_object() else { return false }; + let order: Vec<&str> = value.get("$metadata").map(|m| list(m, "tokenSetOrder").filter_map(Value::as_str).collect()).unwrap_or_default(); + let mut set_names: Vec<&str> = root.iter().filter(|(k, v)| !k.starts_with('$') && v.is_object()).map(|(k, _)| k.as_str()).collect(); + if set_names.is_empty() { + return false; + } + set_names.sort_by_key(|name| order.iter().position(|o| o == name).unwrap_or(usize::MAX)); + let set_names: Vec<&str> = set_names.into_iter().take(512).collect(); + let mut tokens: BTreeMap<&str, BTreeMap> = BTreeMap::new(); + for name in &set_names { + let mut flat = Vec::new(); + if let Some(tree) = root.get(*name) { + flatten_tokens(tree, "", &mut flat, 0); + } + tokens.insert(name, flat.into_iter().collect()); + } + // Store only sets that actually define a path, in precedence order. + let set_index: BTreeMap<&str, usize> = set_names.iter().enumerate().map(|(i, s)| (*s, i)).collect(); + let mut path_index: BTreeMap<&str, Vec<(usize, &Value)>> = BTreeMap::new(); + for (set_id, name) in set_names.iter().enumerate() { + for (path, value) in tokens.get(name).into_iter().flat_map(|set| set.iter()) { + path_index.entry(path.as_str()).or_default().push((set_id, *value)); + } + } + let mut groups: Vec<(String, Vec)> = Vec::new(); + for theme in list(value, "$themes").take(256) { + let Some(name) = string(theme, "name") else { continue }; + let group = string(theme, "group").filter(|g| !g.trim().is_empty()).unwrap_or("Themes").to_owned(); + let sets = theme + .get("selectedTokenSets") + .and_then(Value::as_object) + .map(|m| { + m.iter() + .filter(|(set, state)| state.as_str().is_some_and(|s| s == "enabled" || s == "source") && tokens.contains_key(set.as_str())) + .map(|(set, _)| set.as_str()) + .collect() + }) + .unwrap_or_default(); + let id = string(theme, "id").unwrap_or(name).chars().take(80).collect(); + match groups.iter_mut().find(|(g, _)| *g == group) { + Some((_, themes)) => themes.push(TokenTheme { id, name, sets }), + None => groups.push((group, vec![TokenTheme { id, name, sets }])), + } + } + let (switching, constant): (Vec<_>, Vec<_>) = groups.into_iter().partition(|(_, themes)| themes.len() > 1); + let themed_sets: BTreeSet<&str> = switching.iter().flat_map(|(_, t)| t.iter().flat_map(|t| t.sets.iter().copied())).collect(); + let active_sets: Vec<&str> = value.get("$metadata").map(|m| list(m, "activeSets").filter_map(Value::as_str).collect()).unwrap_or_default(); + let base_sets: BTreeSet<&str> = if switching.is_empty() && constant.is_empty() { + set_names.iter().copied().filter(|s| active_sets.is_empty() || active_sets.contains(s)).collect() + } else { + constant + .iter() + .flat_map(|(_, t)| t.iter().flat_map(|t| t.sets.iter().copied())) + .chain(active_sets.iter().copied().filter(|s| !themed_sets.contains(s))) + .filter(|s| tokens.contains_key(s)) + .collect() + }; + let ordered = |sets: &BTreeSet<&str>| set_names.iter().copied().filter(|s| sets.contains(s)).collect::>(); + let base_order = ordered(&base_sets); + let collection_id = |group: &str| format!("penpot:tokens:{base}:collection:{group}"); + let mut plans: Vec = + vec![(collection_id("base"), "Tokens".into(), vec![(format!("{}:default", collection_id("base")), "Default".into(), base_order.clone())])]; + for (group, themes) in &switching { + let cid = collection_id(group); + let modes = themes + .iter() + .map(|t| { + let sets: BTreeSet<&str> = base_sets.iter().copied().chain(t.sets.iter().copied()).collect(); + (format!("{cid}:{}", t.id), t.name.chars().take(120).collect(), ordered(&sets)) + }) + .collect(); + plans.push((cid, group.chars().take(120).collect(), modes)); + } + let mode_sets: BTreeMap<&str, BTreeSet> = plans + .iter() + .flat_map(|(_, _, modes)| { + modes.iter().map(|(id, _, sets)| (id.as_str(), sets.iter().filter_map(|name| set_index.get(name).copied()).collect())) + }) + .collect(); + let base_enabled: BTreeSet = base_order.iter().filter_map(|name| set_index.get(name).copied()).collect(); + // One budget across all modes, kinds, descriptions and token expressions. + let mut resolution_budget = TokenResolutionBudget { remaining: MAX_TOKEN_RESOLUTION_WORK }; + let mut owner: BTreeMap = BTreeMap::new(); + for (index, (_, _, modes)) in plans.iter().enumerate().rev() { + for (_, _, sets) in modes { + for set in sets { + if index > 0 && base_sets.contains(set) { + continue; + } + for path in tokens.get(set).into_iter().flat_map(|t| t.keys()) { + if resolution_budget.spend(1).is_none() { + warn(warnings, "Penpot token lookup work budget exhausted; skipped themed tokens".into()); + return false; + } + owner.insert(path.clone(), index); + } + } + } + } + for _ in 0..owner.len().min(64) { + let moves: Vec<(String, usize)> = owner + .iter() + .filter(|(_, index)| **index == 0) + .filter_map(|(path, _)| { + resolution_budget.spend(1)?; + let token = indexed_token(path, &base_enabled, &path_index, &mut resolution_budget)?; + if token_alias(token).is_some() { + return None; + } + let text = token.get("$value").or_else(|| token.get("value"))?.as_str()?; + resolution_budget.spend(text.len())?; + text.split('{') + .skip(1) + .filter_map(|part| part.split_once('}').map(|(r, _)| r.trim())) + .find_map(|r| owner.get(r).copied().filter(|i| *i != 0)) + .map(|i| (path.clone(), i)) + }) + .collect(); + if resolution_budget.remaining == 0 { + warn(warnings, "Penpot token lookup work budget exhausted; skipped themed tokens".into()); + return false; + } + if moves.is_empty() { + break; + } + owner.extend(moves); + } + let var_id = |path: &str| format!("penpot:tokens:{base}:{path}"); + let mut kinds = BTreeMap::new(); + for path in owner.keys() { + let kind = path_index.get(path.as_str()).and_then(|occurrences| { + occurrences.iter().rev().find_map(|(_, token)| { + resolution_budget.spend(1)?; + token_kind(token) + }) + }); + if resolution_budget.remaining == 0 { + warn(warnings, "Penpot token lookup work budget exhausted; skipped themed tokens".into()); + return false; + } + if let Some(kind) = kind { + kinds.insert(path.as_str(), kind); + } + } + let mut resolved: BTreeMap<&str, (usize, VariableType, BTreeMap)> = BTreeMap::new(); + let mut skipped = 0usize; + for (path, &index) in &owner { + let Some((_, _, modes)) = plans.get(index) else { continue }; + let Some(&kind) = kinds.get(path.as_str()) else { + skipped += 1; + continue; + }; + let mut values = BTreeMap::new(); + for (mode_id, _, sets) in modes { + let Some(selected) = mode_sets.get(mode_id.as_str()) else { continue }; + let Some(token) = indexed_token(path, selected, &path_index, &mut resolution_budget) else { continue }; + let value = match token_alias(token) { + Some(target) if kinds.get(target) == Some(&kind) => Some(VariableValue::Alias(var_id(target))), + Some(_) => None, + None => scalar_token(token).filter(|(k, _)| *k == kind).map(|(_, v)| v).or_else(|| { + (kind == VariableType::Number) + .then(|| token_number(path, sets, &tokens, 0, 0, &mut resolution_budget)) + .flatten() + .map(VariableValue::Number) + }), + }; + if let Some(value) = value { + values.insert(mode_id.clone(), value); + } + } + if resolution_budget.remaining == 0 { + warn(warnings, "Penpot token lookup work budget exhausted; skipped themed tokens".into()); + return false; + } + let Some(fallback) = modes.iter().find_map(|(m, _, _)| values.get(m).cloned()) else { + skipped += 1; + continue; + }; + for (mode_id, _, _) in modes { + values.entry(mode_id.clone()).or_insert_with(|| fallback.clone()); + } + resolved.insert(path.as_str(), (index, kind, values)); + } + loop { + let ids: BTreeSet = resolved.keys().map(|p| var_id(p)).collect(); + let dangling: Vec<&str> = resolved + .iter() + .filter(|(_, (_, _, values))| values.values().any(|v| matches!(v, VariableValue::Alias(target) if !ids.contains(target)))) + .map(|(path, _)| *path) + .collect(); + if dangling.is_empty() { + break; + } + skipped += dangling.len(); + for path in dangling { + resolved.remove(path); + } + } + let mut converted = 0usize; + let mut used_collections = BTreeSet::new(); + for (path, (index, kind, values)) in resolved { + if doc.variables.variables.len() >= 8192 { + skipped += 1; + continue; + } + let Some((collection, group, _)) = plans.get(index) else { continue }; + let description = path_index + .get(path) + .and_then(|occurrences| { + occurrences.iter().rev().find_map(|(_, token)| { + resolution_budget.spend(1)?; + string(token, "$description").or_else(|| string(token, "description")) + }) + }) + .unwrap_or_default(); + if resolution_budget.remaining == 0 { + warn(warnings, "Penpot token lookup work budget exhausted; remaining descriptions were skipped".into()); + break; + } + doc.variables.variables.push(Variable { + id: var_id(path), + name: format!("{group}/{path}"), + collection: collection.clone(), + kind, + values, + description: description.chars().take(1024).collect(), + scopes: None, + hidden_from_publishing: false, + extends: None, + overrides: BTreeSet::new(), + code_syntax: BTreeMap::new(), + }); + used_collections.insert(index); + converted += 1; + } + for (index, (id, name, modes)) in plans.iter().enumerate() { + if !used_collections.contains(&index) || doc.variables.collections.len() >= 256 { + continue; + } + doc.variables.collections.push(Collection { + id: id.clone(), + name: name.clone(), + modes: modes.iter().map(|(mode, name, _)| Mode { id: mode.clone(), name: name.clone() }).collect(), + extends: None, + }); + } + let active_themes: Vec<&str> = value.get("$metadata").map(|m| list(m, "activeThemes").filter_map(Value::as_str).collect()).unwrap_or_default(); + for (group, themes) in &switching { + let active = themes.iter().find(|t| active_themes.contains(&format!("{group}/{}", t.name).as_str())); + if let Some(theme) = active { + active_modes.insert(collection_id(group), format!("{}:{}", collection_id(group), theme.id)); + } + } + if converted > 0 { + warn( + warnings, + format!( + "Imported {converted} Penpot design tokens as native variables in {} collection(s); theme groups became modes", + used_collections.len() + ), + ); + } + if skipped > 0 { + warn( + warnings, + format!( + "{skipped} Penpot tokens (math expressions, composite typography/shadow values or mismatched references) were not converted; original JSON retained when possible" + ), + ); + } + true +} + +/// Import supported scalar tokens and retain their original source. +fn import_token_variables( + z: &mut ZipArchive>, + base: &str, + doc: &mut Document, + active_modes: &mut BTreeMap, + warnings: &mut Vec, + budget: &mut InflateBudget, +) -> Option { + let path = format!("{base}/tokens.json"); + z.index_for_name(&path)?; + let value = match json(z, &path, budget) { + Ok(v) => v, + Err(_) => { + warn(warnings, "Penpot tokens.json could not be read; tokens not imported".into()); + return None; + } + }; + import_composite_token_styles(&value, base, doc, warnings); + let source = serde_json::to_string(&value).ok().filter(|s| s.len() <= 256 * 1024); + if source.is_none() { + warn(warnings, "Penpot tokens library exceeds 256 KiB metadata retention budget; only supported native variables will be saved".into()); + } + let Some(sets) = value.get("sets").and_then(Value::as_object) else { + if !import_themed_tokens(&value, base, doc, active_modes, warnings) { + warn(warnings, "Unrecognized Penpot token sets format; original JSON retained when possible".into()); + } + return source; + }; + let mut count = 0usize; + let mut skipped = 0usize; + for (set_name, definition) in sets.iter().take(256) { + if doc.variables.collections.len() >= 256 { + warn(warnings, "Penpot variable collections capped at 256 across the document".into()); + break; + } + let Some(tokens) = definition.get("tokens") else { + skipped += 1; + continue; + }; + let collection_id = format!("penpot:tokens:{base}:{set_name}"); + let mode_id = format!("{collection_id}:default"); + let mut flattened = Vec::new(); + flatten_tokens(tokens, "", &mut flattened, 0); + let available = 8192usize.saturating_sub(doc.variables.variables.len()); + let flattened: Vec<_> = flattened.into_iter().take(available).collect(); + let index: BTreeMap<&str, &Value> = flattened.iter().map(|(name, token)| (name.as_str(), *token)).collect(); + let mut converted = 0usize; + for (name, token) in &flattened { + let token = *token; + let converted_token = if let Some(target) = token_alias(token) { + let mut visiting = BTreeSet::new(); + visiting.insert(name.as_str().to_owned()); + match (token_kind(token), resolved_token_kind(target, &index, &mut visiting, 0)) { + (Some(kind), Some(target_kind)) if kind == target_kind => Some((kind, VariableValue::Alias(format!("{collection_id}:{target}")))), + _ => None, // unresolved, cross-set, cyclic or mismatched types + } + } else { + scalar_token(token) + }; + if let Some((kind, item)) = converted_token { + let mut values = BTreeMap::new(); + values.insert(mode_id.clone(), item); + doc.variables.variables.push(Variable { + id: format!("{collection_id}:{name}"), + name: format!("{set_name}/{name}"), + collection: collection_id.clone(), + kind, + values, + description: string(token, "description") + .or_else(|| string(token, "$description")) + .unwrap_or_default() + .chars() + .take(1024) + .collect(), + scopes: None, + hidden_from_publishing: false, + extends: None, + overrides: BTreeSet::new(), + code_syntax: BTreeMap::new(), + }); + count += 1; + converted += 1; + } else { + skipped += 1; + } + } + if converted > 0 { + doc.variables.collections.push(Collection { + id: collection_id, + name: format!("Penpot/{set_name}"), + modes: vec![Mode { id: mode_id, name: "Default".into() }], + extends: None, + }); + } + } + if sets.len() > 256 || doc.variables.variables.len() >= 8192 { + warn(warnings, "Penpot token import capped at 256 sets and 8192 scalar variables".into()); + } + if skipped > 0 { + warn( + warnings, + format!("{skipped} unsupported or unresolved Penpot tokens were not converted to native variables; original JSON retained when possible"), + ); + } + if value.get("themes").is_some_and(|v| !v.is_null()) { + warn(warnings, "Penpot themes and active set combinations are preserved as source metadata, not editable FigCraft modes".into()); + } + if count > 0 { + warn( + warnings, + format!( + "Imported {count} Penpot scalar/alias design tokens as native variables; matching layer bindings will be linked when unambiguous" + ), + ); + } + source +} + +/// A source variant reference is meaningful even when its component is left +/// independent. Warn instead of marking such layers as linked editable variants. +fn report_design_system_limits(doc: &Document, warnings: &mut Vec) { + let in_set = |node: &figcraft_doc::Node| { + matches!(node.kind, NodeKind::Component { .. }) + && node.parent.and_then(|p| doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })) + }; + let variants = doc + .nodes() + .filter(|node| node.plugin_data.contains_key("penpot.variantId") || node.plugin_data.contains_key("penpot.source.variantProperties")) + .filter(|node| !in_set(node)) + .count(); + if variants > 0 { + warn(warnings, format!("{variants} Penpot variant layers are not inside a variant set; variant switching is unavailable for them")); + } + let missing = doc.nodes().filter(|node| node.plugin_data.get("penpot.componentLinkStatus").is_some_and(|status| status == "not-linked")).count(); + if missing > 0 { + warn(warnings, format!("{missing} component copies have no native link; editing their masters will not update those copies")); + } +} + +/// File library components point at an existing page shape (`mainInstanceId`). Use this to turn +/// that original into a native FigCraft component. Copies are still independent layers until the +/// Penpot override/source graph can be translated safely (see docs). +fn component_masters( + z: &mut ZipArchive>, + base: &str, + budget: &mut InflateBudget, +) -> (BTreeMap, BTreeMap, BTreeMap) { + let prefix = format!("{base}/components/"); + let paths = z + .file_names() + .filter(|name| name.strip_prefix(&prefix).is_some_and(|part| part.len() == 41 && part.ends_with(".json") && part.get(..36).is_some_and(uuid))) + .take(10001) + .map(str::to_owned) + .collect::>(); + let mut result = BTreeMap::new(); + let mut variant_names = BTreeMap::new(); + let mut sources = BTreeMap::new(); + if paths.len() > 10000 { + return (result, variant_names, sources); + } + for path in paths { + if let Ok(entry) = json(z, &path, budget) + && let (Some(shape), Some(component)) = + (string(&entry, "mainInstanceId").filter(|id| uuid(id)), string(&entry, "id").filter(|id| uuid(id))) + { + result.insert(shape.to_owned(), component.to_owned()); + if let Some(name) = variant_name(&entry) { + variant_names.insert(shape.to_owned(), name); + } + let kept: serde_json::Map = + entry.as_object().into_iter().flatten().filter(|(key, _)| key.as_str() != "objects").map(|(k, v)| (k.clone(), v.clone())).collect(); + if let Ok(encoded) = serde_json::to_string(&kept) + && encoded.len() <= 16_384 + { + sources.insert(shape.to_owned(), encoded); + } + } + } + (result, variant_names, sources) +} + +/// Read a component variant label from its Penpot metadata. +fn variant_name(component: &Value) -> Option { + let clean = |s: &str| s.replace([',', '='], " ").trim().chars().take(200).collect::(); + let pairs: Vec = list(component, "variantProperties") + .take(64) + .filter_map(|p| Some((clean(string(p, "name")?), clean(string(p, "value").unwrap_or(""))))) + .filter(|(k, _)| !k.is_empty()) + .map(|(k, v)| format!("{k}={v}")) + .collect(); + (!pairs.is_empty()).then(|| pairs.join(", ")) +} + +/// Apply saved variant names to imported component definitions. +fn name_variants(doc: &mut Document, file_id: &str, variant_names: &BTreeMap) { + let ids: Vec = doc + .nodes() + .filter(|n| matches!(n.kind, NodeKind::Component { .. })) + .filter(|n| n.plugin_data.get("penpot.fileId").map(String::as_str) == Some(file_id)) + .filter(|n| n.parent.and_then(|p| doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }))) + .map(|n| n.id) + .collect(); + for id in ids { + let Some(node) = doc.node_mut(id) else { continue }; + let Some(name) = node.plugin_data.get("penpot.id").and_then(|pid| variant_names.get(pid)).cloned() else { continue }; + let original = std::mem::replace(&mut node.name, name); + node.plugin_data.insert("penpot.originalName".into(), original); + } +} + +/// Append a warning while bounding the size of the import report. +fn warn(out: &mut Vec, message: String) { + if out.len() < 100 && !out.contains(&message) { + out.push(message); + } +} + +/// Serialize Penpot's inline raw SVG without using an external process. The SVG importer +/// resolves curves, gradients and other supported elements into ordinary editable nodes. +/// Some exports contain a full XML string, others a `{tag, attrs, content}` tree. +/// Both representations are bounded before they enter the document. +fn svg_name(s: &str) -> bool { + let mut bytes = s.bytes(); + bytes.next().is_some_and(|c| c.is_ascii_alphabetic() || matches!(c, b'_' | b':')) + && s.len() <= 100 + && bytes.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b':' | b'.')) +} + +/// Escape XML text or attribute content before rebuilding raw SVG. +fn svg_escape(value: &str, attribute: bool, out: &mut String) { + for c in value.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' if attribute => out.push_str("""), + '\'' if attribute => out.push_str("'"), + c if c.is_control() && !matches!(c, '\n' | '\r' | '\t') => {} + c => out.push(c), + } + } +} + +/// Serialize a supported SVG node tree within the nesting and output budgets. +fn svg_tree(value: &Value, output: &mut String, depth: usize) -> Option<()> { + if depth > 64 || output.len() > 128 * 1024 { + return None; + } + match value { + Value::String(s) => { + svg_escape(s, false, output); + } + Value::Array(items) => { + if items.len() > 8192 { + return None; + } + for child in items { + svg_tree(child, output, depth + 1)?; + } + } + Value::Object(_) => { + let tag = string(value, "tag")?; + if !svg_name(tag) { + return None; + } + output.push('<'); + output.push_str(tag); + if let Some(attrs) = value.get("attrs").and_then(Value::as_object) { + if attrs.len() > 256 { + return None; + } + for (key, val) in attrs { + if !svg_name(key) || key.to_ascii_lowercase().starts_with("on") { + continue; + } + let scalar = match val { + Value::String(v) => v.as_str(), + Value::Bool(_) | Value::Number(_) => { + // Render a bounded string; do not feed arbitrary JSON as an XML attribute. + output.push(' '); + output.push_str(key); + output.push_str("=\""); + svg_escape(&val.to_string(), true, output); + output.push('"'); + continue; + } + _ => continue, + }; + output.push(' '); + output.push_str(key); + output.push_str("=\""); + svg_escape(scalar, true, output); + output.push('"'); + } + } + output.push('>'); + if let Some(content) = value.get("content") { + svg_tree(content, output, depth + 1)?; + } + output.push_str("'); + } + Value::Null => {} + _ => return None, + } + (output.len() <= 128 * 1024).then_some(()) +} + +/// Recover inline SVG markup or a serialized SVG tree from a raw shape. +fn raw_svg_xml(shape: &Value) -> Option { + let attrs = shape.get("svgAttrs").or_else(|| shape.get("svg-attrs")); + let raw = attrs.and_then(|a| string(a, "raw")).or_else(|| shape.get("content").and_then(Value::as_str)); + if let Some(raw) = raw { + let raw = raw.trim(); + if raw.len() > 128 * 1024 || raw.is_empty() || !raw.starts_with('<') { + return None; + } + if raw.starts_with("{raw}")); + } + let content = shape.get("content")?; + let mut xml = String::new(); + svg_tree(content, &mut xml, 0)?; + if xml.starts_with("") { + if xml.split('>').next().is_some_and(|header| header.contains("xmlns=")) { + return Some(xml); + } + return Some(xml.replacen("{xml}")) +} + +/// Parse a finite SVG length or percentage with a fallback for missing values. +fn svg_unit(raw: Option<&str>, default: f64) -> Option { + let Some(raw) = raw else { return Some(default) }; + let trimmed = raw.trim(); + let n = match trimmed.strip_suffix('%') { + Some(percent) => percent.trim().parse::().ok()? / 100.0, + None => trimmed.parse::().ok()?, + }; + (n.is_finite() && n.abs() <= 10_000.0).then_some(n) +} + +/// Parse an SVG coordinate expressed in user-space units. +fn svg_user_unit(raw: Option<&str>) -> Option { + let n = raw?.trim().trim_end_matches("px").parse::().ok()?; + (n.is_finite() && n.abs() <= 1_000_000.0).then_some(n) +} + +/// Find a property in a semicolon-separated inline CSS declaration. +fn css_property<'a>(style: &'a str, key: &str) -> Option<&'a str> { + style.split(';').find_map(|decl| { + let (k, v) = decl.split_once(':')?; + (k.trim() == key).then(|| v.trim()) + }) +} + +/// Read an SVG gradient stop color and its opacity. +fn svg_stop_color(node: &Value) -> Option { + let attrs = node.get("attrs")?; + let style = attrs.get("style"); + let css = style.and_then(Value::as_str); + let color = string(attrs, "stop-color") + .or_else(|| string(attrs, "stopColor")) + .or_else(|| style.and_then(|s| string(s, "stopColor"))) + .or_else(|| css.and_then(|s| css_property(s, "stop-color")))?; + let c = Color::from_hex(color)?; + let opacity = string(attrs, "stop-opacity") + .or_else(|| string(attrs, "stopOpacity")) + .or_else(|| style.and_then(|s| string(s, "stopOpacity"))) + .or_else(|| css.and_then(|s| css_property(s, "stop-opacity"))) + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| number(attrs, "stop-opacity", number(attrs, "stopOpacity", 1.0))); + Some(c.with_alpha((f64::from(c.a) * opacity.clamp(0.0, 1.0)) as f32)) +} + +/// Parse a supported SVG transform list into a native affine matrix. +fn svg_transform_list(raw: &str) -> Option { + let mut result = Affine::IDENTITY; + let mut rest = raw.trim(); + while !rest.is_empty() { + let open = rest.find('(')?; + let close = rest.find(')')?; + let name = rest.get(..open)?.trim().trim_start_matches(',').trim(); + let args: Vec = rest + .get(open + 1..close)? + .split(|c: char| c == ',' || c.is_whitespace()) + .filter(|a| !a.is_empty()) + .map(|a| a.parse::().ok()) + .collect::>()?; + let step = match (name, args.as_slice()) { + ("matrix", [a, b, c, d, e, f]) => Affine::new([*a, *b, *c, *d, *e, *f]), + ("translate", [x]) => Affine::translate((*x, 0.0)), + ("translate", [x, y]) => Affine::translate((*x, *y)), + ("scale", [k]) => Affine::scale(*k), + ("scale", [x, y]) => Affine::scale_non_uniform(*x, *y), + ("rotate", [a]) => Affine::rotate(a.to_radians()), + ("rotate", [a, x, y]) => Affine::translate((*x, *y)) * Affine::rotate(a.to_radians()) * Affine::translate((-x, -y)), + ("skewX", [a]) => Affine::new([1.0, 0.0, a.to_radians().tan(), 1.0, 0.0, 0.0]), + ("skewY", [a]) => Affine::new([1.0, a.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]), + _ => return None, + }; + result *= step; + rest = rest.get(close + 1..)?.trim(); + } + result.as_coeffs().iter().all(|c| c.is_finite()).then_some(result) +} + +/// Index gradient definitions in a bounded SVG tree traversal. +fn svg_gradient_nodes<'a>(value: &'a Value, key: Option<&str>, depth: usize, out: &mut BTreeMap) { + if depth > 32 || out.len() > 1024 { + return; + } + if matches!(string(value, "tag"), Some("linearGradient" | "radialGradient")) + && let Some(id) = value.get("attrs").and_then(|a| string(a, "id")).or(key) + { + out.entry(id.to_owned()).or_insert(value); + } + if let Some(items) = value.as_array() { + for item in items.iter().take(1024) { + svg_gradient_nodes(item, None, depth + 1, out); + } + } else if let Some(children) = value.get("content") { + svg_gradient_nodes(children, None, depth + 1, out); + } else if let Some(fields) = value.as_object() { + for (k, child) in fields.iter().take(1024) { + svg_gradient_nodes(child, Some(k), depth + 1, out); + } + } +} + +/// Read a gradient reference from href or its legacy xlink spelling. +fn svg_href(attrs: &Value) -> Option<&str> { + string(attrs, "xlinkHref").or_else(|| string(attrs, "href")).or_else(|| string(attrs, "xlink:href"))?.trim().strip_prefix('#') +} + +/// Resolve inherited gradient attributes without following cyclic references. +fn svg_gradient_chain<'a>(defs: &BTreeMap, id: &str) -> Option<(bool, serde_json::Map, &'a Value)> { + let mut node = *defs.get(id)?; + let radial = string(node, "tag")? == "radialGradient"; + let mut attrs = serde_json::Map::new(); + let mut stops = None; + let mut visited = BTreeSet::new(); + for _ in 0..8 { + let own = node.get("attrs")?; + for (k, v) in own.as_object()? { + attrs.entry(k.clone()).or_insert_with(|| v.clone()); + } + if stops.is_none() && list(node, "content").any(|c| string(c, "tag") == Some("stop")) { + stops = Some(node); + } + visited.insert(string(own, "id").unwrap_or(id).to_owned()); + match svg_href(own).filter(|h| !visited.contains(*h)).and_then(|h| defs.get(h)) { + Some(next) => node = next, + None => break, + } + } + Some((radial, attrs, stops?)) +} + +/// Convert SVG stops and handles into a native gradient. +fn svg_gradient(radial: bool, attrs: &serde_json::Map, stops_node: &Value, to_unit: Option) -> Option { + let attrs = Value::Object(attrs.clone()); + let attrs = &attrs; + if string(attrs, "spreadMethod").is_some_and(|s| s != "pad") { + return None; + } + let user_space = match string(attrs, "gradientUnits") { + None | Some("objectBoundingBox") => false, + Some("userSpaceOnUse") => true, + Some(_) => return None, + }; + let coord = |key: &str, default: f64| if user_space { svg_user_unit(string(attrs, key)) } else { svg_unit(string(attrs, key), default) }; + let (from, to, width) = if radial { + let cx = coord("cx", 0.5)?; + let cy = coord("cy", 0.5)?; + let r = coord("r", 0.5)?; + let fx = if string(attrs, "fx").is_some() { coord("fx", cx)? } else { cx }; + let fy = if string(attrs, "fy").is_some() { coord("fy", cy)? } else { cy }; + // Native radial gradients cannot place an off-centre SVG focal point exactly. + if r <= 0.0 || (fx - cx).abs() > 1e-6 || (fy - cy).abs() > 1e-6 { + return None; + } + ((cx, cy), (cx + r, cy), (cx, cy + r)) + } else { + let x1 = coord("x1", 0.0)?; + let y1 = coord("y1", 0.0)?; + let x2 = coord("x2", 1.0)?; + let y2 = coord("y2", 0.0)?; + ((x1, y1), (x2, y2), (x1 - (y2 - y1), y1 + x2 - x1)) + }; + let gradient_transform = match string(attrs, "gradientTransform") { + Some(raw) => svg_transform_list(raw)?, + None => Affine::IDENTITY, + }; + let space = if user_space { to_unit? * gradient_transform } else { gradient_transform }; + let map = |p: (f64, f64)| { + let q = space * figcraft_doc::geom::Point::new(p.0, p.1); + (q.x.is_finite() && q.y.is_finite() && q.x.abs() <= 1e6 && q.y.abs() <= 1e6).then_some((q.x, q.y)) + }; + let (from, to, width) = (map(from)?, map(to)?, map(width)?); + let mut stops = Vec::new(); + for stop in list(stops_node, "content").take(128) { + if string(stop, "tag") != Some("stop") { + continue; + } + let a = stop.get("attrs")?; + let pos = svg_unit(string(a, "offset"), number(a, "offset", 0.0))?; + stops.push(ColorStop { position: pos.clamp(0.0, 1.0) as f32, color: svg_stop_color(stop)? }); + } + if stops.is_empty() { + return None; + } + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + Some(Gradient { kind: if radial { GradientKind::Radial } else { GradientKind::Linear }, stops, from, to, width }) +} + +/// Read a named SVG gradient definition when its geometry is supported. +fn svg_gradient_definition(value: &Value) -> Option<(String, Gradient)> { + let radial = match string(value, "tag")? { + "linearGradient" => false, + "radialGradient" => true, + _ => return None, + }; + let attrs = value.get("attrs")?; + if svg_href(attrs).is_some() || string(attrs, "gradientUnits").is_some_and(|s| s != "objectBoundingBox") { + return None; + } + let id = string(attrs, "id")?; + if id.is_empty() || id.len() > 200 || !id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b':')) { + return None; + } + Some((id.to_owned(), svg_gradient(radial, attrs.as_object()?, value, None)?)) +} + +/// Map saved SVG coordinates into the shape coordinate space. +fn svg_space_matrix(v: &Value) -> Option { + let m = match v.get("svgTransform").filter(|t| t.is_object()) { + Some(t) => Affine::new([ + number(t, "a", 1.0), + number(t, "b", 0.0), + number(t, "c", 0.0), + number(t, "d", 1.0), + number(t, "e", 0.0), + number(t, "f", 0.0), + ]), + None => Affine::IDENTITY, + }; + m.as_coeffs().iter().all(|c| c.is_finite()).then_some(m) +} + +/// Collect usable SVG gradients while tracking malformed definitions. +fn scan_svg_gradient_trees(value: &Value, depth: usize, count: &mut usize, out: &mut BTreeMap, bad: &mut BTreeSet) { + if depth > 32 || *count > 8192 { + return; + } + *count += 1; + if let Some((id, gradient)) = svg_gradient_definition(value) + && out.insert(id.clone(), gradient).is_some() + { + bad.insert(id); + } + if let Some(items) = value.as_array() { + for item in items.iter().take(1024) { + scan_svg_gradient_trees(item, depth + 1, count, out, bad); + } + } else if let Some(children) = value.get("content") { + scan_svg_gradient_trees(children, depth + 1, count, out, bad); + } else if let Some(fields) = value.as_object() { + for child in fields.values().take(1024) { + scan_svg_gradient_trees(child, depth + 1, count, out, bad); + } + } +} + +/// Resolve reusable SVG gradients from the imported shape metadata. +fn svg_gradients(shapes: &BTreeMap) -> BTreeMap { + let mut result = BTreeMap::new(); + let mut duplicates = BTreeSet::new(); + let mut count = 0; + for shape in shapes.values() { + if count > 8192 { + break; + } + if let Some(tree) = shape.get("content") { + scan_svg_gradient_trees(tree, 0, &mut count, &mut result, &mut duplicates); + } + if let Some(defs) = shape.get("svgDefs") { + scan_svg_gradient_trees(defs, 0, &mut count, &mut result, &mut duplicates); + } + } + for id in duplicates { + result.remove(&id); + } + result +} + +/// Extract the local identifier of an SVG url paint reference. +fn svg_paint_ref(text: &str) -> Option<&str> { + let id = text.trim().strip_prefix("url(#")?.strip_suffix(')')?.trim(); + (!id.is_empty() && id.len() <= 200).then_some(id) +} + +/// Convert one shape and its children, interpreting Penpot's absolute x/y relative to the parent. +#[allow(clippy::too_many_arguments)] +/// Import one shape and its descendants within hierarchy and asset budgets. +fn add_layer( + z: &mut ZipArchive>, + base: &str, + shapes: &BTreeMap, + children_by_parent: &BTreeMap>, + svg_defs: &BTreeMap, + masters: &BTreeMap, + id: &str, + parent: NodeId, + parent_abs: Affine, + cell: Option<&Value>, + doc: &mut Document, + images: &mut BTreeMap, + image_bytes: &mut usize, + inflate_budget: &mut InflateBudget, + svg_bytes: &mut usize, + warnings: &mut Vec, + seen: &mut BTreeSet, + depth: usize, + count: &mut usize, +) -> Result<(), FormatError> { + if depth >= MAX_DEPTH { + warn(warnings, "Penpot layer nesting deeper than 128 was omitted".into()); + return Ok(()); + } + if !seen.insert(id.to_owned()) { + return Ok(()); + } + let Some(v) = shapes.get(id) else { + warn(warnings, format!("Missing layer {id}")); + return Ok(()); + }; + *count += 1; + if *count > MAX_LAYERS { + return Err(corrupt("too many layers")); + } + let name = string(v, "name").unwrap_or("Layer").chars().take(512).collect::(); + let x = geometry(v, "x", 0.0); + let y = geometry(v, "y", 0.0); + let w = geometry(v, "width", 1.0).clamp(0.01, 1_000_000.0); + let h = geometry(v, "height", 1.0).clamp(0.01, 1_000_000.0); + let typ = string(v, "type").unwrap_or(""); + let mut path_box = None; + let mut vector_source = None; + let kind = match typ { + "frame" if masters.contains_key(id) => NodeKind::Component { description: String::new(), props: BTreeMap::new() }, + "frame" if v.get("isVariantContainer").and_then(Value::as_bool) == Some(true) => NodeKind::ComponentSet { description: String::new() }, + "frame" => NodeKind::Frame, + "group" => NodeKind::Group, + "bool" => NodeKind::BooleanOp { + op: match string(v, "boolType") { + Some("difference") => BoolOp::Subtract, + Some("intersection") => BoolOp::Intersect, + Some("exclude" | "exclusion") => BoolOp::Exclude, + _ => BoolOp::Union, + }, + }, + "rect" | "image" => NodeKind::Rectangle, + // Expanded in the engine using the same native importer as File → Import SVG. + // This parsing crate must not depend on the higher-level SVG/render crates. + "svg-raw" => NodeKind::Frame, + "circle" | "ellipse" => NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, + "path" if v.get("content").and_then(Value::as_array).is_some_and(Vec::is_empty) => NodeKind::Vector { network: VectorNetwork::default() }, + "path" => match vector_bezpath(v, warnings) { + Some(path) => { + let local = shape_matrix(v, x, y, w, h).inverse() * Affine::translate((x, y)) * path; + let bounds = local.bounding_box(); + let origin = if bounds.is_finite() { + path_box = Some(bounds); + bounds.origin() + } else { + Default::default() + }; + let local = Affine::translate(-origin.to_vec2()) * local; + let network = VectorNetwork::from_path(&local); + vector_source = Some(local); + NodeKind::Vector { network } + } + None => { + warn(warnings, "A Penpot path could not be decoded; kept as a frame".into()); + NodeKind::Frame + } + }, + "text" => NodeKind::Text(text_node(v)), + other => { + warn(warnings, format!("Unsupported Penpot layer type '{other}': imported as frame")); + NodeKind::Frame + } + }; + let abs = if kind.bounds_follow_children() { Affine::translate((x, y)) } else { shape_matrix(v, x, y, w, h) }; + let (abs, w, h) = match path_box { + Some(b) => (abs * Affine::translate(b.origin().to_vec2()), b.width().max(0.01), b.height().max(0.01)), + None => (abs, w, h), + }; + let created = doc.create(parent, kind, &name, 0.0, 0.0, w, h).map_err(|e| corrupt(format!("layer '{name}': {e}")))?; + let mut fills = paints(z, base, v, "fills", doc, images, image_bytes, inflate_budget, warnings); + // Some Penpot versions keep a stand-alone image's reference in `metadata`, not `fills`. + if typ == "image" + && let Some(metadata) = v.get("metadata") + { + let mut stretched = metadata.clone(); + if let Some(obj) = stretched.as_object_mut() { + obj.insert("keepAspectRatio".into(), Value::Bool(false)); + } + let mut all = vec![serde_json::json!({"fillImage": stretched, "fillOpacity": 1.0})]; + all.extend(list(v, "fills").cloned()); + let temporary = serde_json::json!({ "fills": all }); + fills = paints(z, base, &temporary, "fills", doc, images, image_bytes, inflate_budget, warnings); + } + if matches!(typ, "rect" | "circle" | "path" | "bool") { + let svg = v.get("svgAttrs"); + let style_fill = svg.and_then(|a| a.get("style")).and_then(|s| string(s, "fill")); + let attr_fill = svg.and_then(|a| string(a, "fill")); + let source = match style_fill { + Some(fill) if Color::from_hex(fill).is_some() => Some(serde_json::json!({"fills": [{"fillColor": fill}]})), + Some(_) => None, + None if list(v, "fills").next().is_some() => None, + None => Some(match attr_fill { + Some(fill) => serde_json::json!({"fills": [{"fillColor": fill}]}), + None => inherited_fill(shapes, v), + }), + }; + if let Some(source) = source { + fills = paints(z, base, &source, "fills", doc, images, image_bytes, inflate_budget, warnings); + } + if let Some(reference) = style_fill.or(attr_fill).and_then(svg_paint_ref) + && fills.is_empty() + { + let mut local = BTreeMap::new(); + if let Some(defs) = v.get("svgDefs") { + svg_gradient_nodes(defs, None, 0, &mut local); + } + let resolved = if local.contains_key(reference) { + let to_local = if typ == "path" { abs.inverse() } else { Affine::translate((-x, -y)) }; + let to_unit = svg_space_matrix(v).map(|m| Affine::scale_non_uniform(1.0 / w, 1.0 / h) * to_local * m); + svg_gradient_chain(&local, reference).and_then(|(radial, attrs, stops)| svg_gradient(radial, &attrs, stops, to_unit)) + } else { + svg_defs.get(reference).cloned() + }; + if let Some(g) = resolved { + fills = vec![Paint::gradient(g)]; + } else { + warn( + warnings, + format!("Penpot SVG paint-server url(#{reference}) cannot be represented as a native gradient; original SVG attributes retained"), + ); + } + } + } + let mut strokes = paints(z, base, v, "strokes", doc, images, image_bytes, inflate_budget, warnings); + // Media metadata and image reads are optional, but exhausting the shared + // decompression budget is never optional: abort rather than continue importing. + inflate_budget.check()?; + if typ == "svg-raw" { + fills.clear(); + strokes.clear(); + } + if !fills.is_empty() + && let Some(path) = vector_source + && let Some(node) = doc.node_mut(created) + { + node.kind = NodeKind::Vector { network: filled_network(&path) }; + } + let svg = v.get("svgAttrs"); + let fill_rule = svg.and_then(|a| a.get("style")).and_then(|s| string(s, "fillRule")).or_else(|| svg.and_then(|a| string(a, "fillRule"))); + if fill_rule == Some("evenodd") + && let Some(NodeKind::Vector { network }) = doc.node_mut(created).map(|n| &mut n.kind) + { + for region in &mut network.regions { + region.winding = figcraft_doc::WindingRule::EvenOdd; + } + } + if let Some(node) = doc.node_mut(created) { + // Unlike Node::new's design-tool defaults, absence of a Penpot fill means no fill. + node.fills = fills; + node.strokes = strokes; + node.visible = v.get("hidden").and_then(Value::as_bool) != Some(true); + node.locked = v.get("blocked").and_then(Value::as_bool) == Some(true); + node.opacity = number(v, "opacity", 1.0).clamp(0.0, 1.0) as f32; + node.blend = string(v, "blendMode").and_then(BlendMode::from_name).unwrap_or(BlendMode::Normal); + node.clip = string(v, "type") == Some("frame") && v.get("showContent").and_then(Value::as_bool) != Some(true); + node.is_mask = v.get("mask").and_then(Value::as_bool) == Some(true); + node.mask_type = match string(v, "maskType") { + Some("vector") => MaskType::Vector, + Some("luminance") => MaskType::Luminance, + _ => MaskType::Alpha, + }; + node.constraints.horizontal = match string(v, "constraintsH") { + Some("right") => HConstraint::Right, + Some("leftright") | Some("left-right") => HConstraint::LeftRight, + Some("center") => HConstraint::Center, + Some("scale") => HConstraint::Scale, + _ => HConstraint::Left, + }; + node.constraints.vertical = match string(v, "constraintsV") { + Some("bottom") => VConstraint::Bottom, + Some("topbottom") | Some("top-bottom") => VConstraint::TopBottom, + Some("center") => VConstraint::Center, + Some("scale") => VConstraint::Scale, + _ => VConstraint::Top, + }; + node.effects = effects(v, warnings); + if node.kind.is_frame_like() { + node.grids = display_grids(v, warnings); + node.guides = penpot_guides(v.get("guides")); + } + if typ == "svg-raw" { + // If future SVG support improves, keep the original tree (when small) + // alongside the reconstructed markup for faithful re-import. + preserve_source(node, v, &["content", "svgAttrs", "svg-attrs"]); + if let Some(xml) = raw_svg_xml(v) { + // Avoid unbounded retained markup in huge archives or projects. + if xml.len() <= (8 * 1024 * 1024usize).saturating_sub(*svg_bytes) { + *svg_bytes += xml.len(); + node.plugin_data.insert("penpot.svgRawXml".into(), xml); + node.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into()); + } else { + warn(warnings, format!("SVG layer '{name}' exceeds the 8 MiB raw SVG import budget; kept as frame")); + } + } else { + warn(warnings, format!("SVG layer '{name}' has no safely readable SVG markup; kept as frame")); + } + } + if typ == "text" { + let references = referenced_google_fonts(v); + if !references.is_empty() { + node.plugin_data.insert("penpot.googleFontRefs".into(), serde_json::to_string(&references).unwrap_or_default()); + } + // A `custom-*` identifier refers to Penpot's separately managed + // font service. It is not a URL and does not imply that the binary + // is contained in this `.penpot` archive. Preserve the exact id so + // the desktop importer can report or resolve it without guessing. + let custom = referenced_custom_fonts(v); + if !custom.is_empty() { + node.plugin_data.insert("penpot.customFontRefs".into(), serde_json::to_string(&custom).unwrap_or_default()); + } + } + node.plugin_data.insert("penpot.id".into(), id.to_owned()); + if let Some(file_id) = base.strip_prefix("files/") { + node.plugin_data.insert("penpot.fileId".into(), file_id.to_owned()); + } + if let Some(component) = masters.get(id) { + node.plugin_data.insert("penpot.componentMasterId".into(), component.clone()); + } + if v.get("componentId").is_some() && !masters.contains_key(id) { + node.plugin_data.insert("penpot.componentLinkStatus".into(), "not-linked".into()); + } + apply_child_layout(v, cell, node); + // The prototype links are kept until every page is in the document, when the + // Penpot shape ids they name can be resolved to FigCraft node ids. + prototype::keep_interaction_source(node, v, warnings); + // Mark unsupported properties on their exact source nodes. Layout changes may + // otherwise silently discard these Penpot-specific rules on subsequent edits. + if string(v, "layoutItemAlignSelf") + .is_some_and(|a| !matches!(a, "auto" | "start" | "flex-start" | "center" | "end" | "flex-end" | "stretch" | "baseline")) + { + node.plugin_data.insert("penpot.unsupported.alignSelf".into(), "true".into()); + } + preserve_source( + node, + v, + &[ + "layout", + "layoutMode", + "layoutFlexDir", + "layoutWrapType", + "layoutGap", + "layoutPadding", + "layoutJustifyContent", + "layoutAlignItems", + "layoutAlignContent", + "layoutGridColumns", + "layoutGridRows", + "layoutGridCells", + "layoutItemMargin", + "layoutItemAlignSelf", + "layoutItemAbsolute", + "layoutItemHSizing", + "layoutItemVSizing", + "touched", + "appliedTokens", + "interactions", + "variantProperties", + "pluginData", + "grids", + "guides", + "maskType", + "svgAttrs", + "svgViewbox", + "svgDefs", + "layoutGapType", + "layoutPaddingType", + "layoutJustifyItems", + "layoutGridDir", + "componentRoot", + "mainInstance", + "layoutItemZIndex", + "exports", + "hideInViewer", + "strokes", + ], + ); + if typ == "text" { + preserve_source_within(node, v, &["content", "positionData"], 256 * 1024); + } + for key in ["componentId", "componentFile", "shapeRef", "variantId"] { + if let Some(value) = string(v, key) + && value.len() <= 1024 + { + node.plugin_data.insert(format!("penpot.{key}"), value.to_owned()); + } + } + node.constrain_proportions = v.get("proportionLock").and_then(Value::as_bool) == Some(true); + if let NodeKind::Text(text) = &mut node.kind + && let Some(paints) = uniform_text_fills(text) + { + node.fills = paints; + text.style.fills = None; + for run in &mut text.runs { + run.style.fills = None; + } + } + if typ == "text" && node.fills.is_empty() { + node.fills.push(Paint::solid(Color::BLACK)); + } + let radius = number(v, "rx", 0.0).max(0.0); + node.corners.radii = + [number(v, "r1", radius).max(0.0), number(v, "r2", radius).max(0.0), number(v, "r3", radius).max(0.0), number(v, "r4", radius).max(0.0)]; + node.transform = parent_abs.inverse() * abs; + if let Some(stroke) = list(v, "strokes").next() { + node.stroke.weight = number(stroke, "strokeWidth", 1.0).clamp(0.0, 10000.0); + node.stroke.align = match string(stroke, "strokeAlignment") { + Some("center") => StrokeAlign::Center, + Some("outer") | Some("outside") => StrokeAlign::Outside, + _ => StrokeAlign::Inside, + }; + node.stroke.join = match string(stroke, "strokeJoin") { + Some("round") => StrokeJoin::Round, + Some("bevel") => StrokeJoin::Bevel, + _ => StrokeJoin::Miter, + }; + let cap = match string(stroke, "strokeCapStart") { + Some("round") => StrokeCap::Round, + Some("square") => StrokeCap::Square, + Some("line-arrow") => StrokeCap::LineArrow, + Some("triangle-arrow") => StrokeCap::TriangleArrow, + _ => StrokeCap::None, + }; + node.stroke.cap_start = cap; + node.stroke.cap_end = match string(stroke, "strokeCapEnd") { + Some("round") => StrokeCap::Round, + Some("square") => StrokeCap::Square, + Some("line-arrow") => StrokeCap::LineArrow, + Some("triangle-arrow") => StrokeCap::TriangleArrow, + _ => cap, + }; + let w = node.stroke.weight; + node.stroke.dashes = match string(stroke, "strokeStyle") { + Some("dotted") => vec![0.0, w + 5.0], + Some("dashed") => vec![w + 10.0, w + 10.0], + Some("mixed") => vec![w + 5.0, w + 5.0, w + 1.0, w + 5.0], + _ => Vec::new(), + }; + if string(stroke, "strokeStyle") == Some("dotted") { + let dot = |cap: StrokeCap| if matches!(cap, StrokeCap::LineArrow | StrokeCap::TriangleArrow) { cap } else { StrokeCap::Round }; + (node.stroke.cap_start, node.stroke.cap_end) = (dot(node.stroke.cap_start), dot(node.stroke.cap_end)); + } + } + } + if v.get("layout").is_some() || v.get("layoutMode").is_some() { + let layout = layout_props(v, warnings); + if let Some(node) = doc.node_mut(created) { + if !node.kind.is_frame_like() { + warn(warnings, "Layout on non-frame-like Penpot layer retained as metadata".into()); + } else { + node.layout = layout; + } + } + } + let mut children: Vec = list(v, "shapes").filter_map(Value::as_str).map(str::to_owned).collect(); + if let Some(layout) = + doc.node_mut(created).and_then(|n| n.layout.as_mut()).filter(|l| matches!(l.mode, LayoutMode::Horizontal | LayoutMode::Vertical)) + { + layout.reverse_z = true; + children.reverse(); + } + let cells = grid_cells(v); + for child in children { + add_layer( + z, + base, + shapes, + children_by_parent, + svg_defs, + masters, + &child, + created, + abs, + cells.get(child.as_str()).copied(), + doc, + images, + image_bytes, + inflate_budget, + svg_bytes, + warnings, + seen, + depth + 1, + count, + )?; + } + // `shapes` may be incomplete in a damaged or partially exported document. Recover + // genuine descendants through their declared parent without reordering listed layers. + if let Some(unlinked) = children_by_parent.get(id) { + for child in unlinked { + if !seen.contains(child) { + warn(warnings, "Penpot group omitted a child from its stacking list; recovered it by parentId".into()); + add_layer( + z, + base, + shapes, + children_by_parent, + svg_defs, + masters, + child, + created, + abs, + cells.get(child.as_str()).copied(), + doc, + images, + image_bytes, + inflate_budget, + svg_bytes, + warnings, + seen, + depth + 1, + count, + )?; + } + } + } + finish_layout(doc, created, v, warnings); + finish_masked_group(doc, created, v, warnings); + Ok(()) +} + +/// Exact font-provider references supplied by Penpot's text content tree. +/// Preserve these independently of display names; a name alone does not prove a +/// font comes from Google Fonts, and an archive may contain mixed-style runs. +fn referenced_google_fonts(source: &Value) -> Vec { + referenced_font_refs(source, "gfont-") +} + +/// Collect custom font references for diagnostics without resolving them remotely. +fn referenced_custom_fonts(source: &Value) -> Vec { + referenced_font_refs(source, "custom-") +} + +/// Collect bounded provider references and their requested font variants. +fn referenced_font_refs(source: &Value, provider_prefix: &str) -> Vec { + #[derive(Clone)] + struct FontContext { + id: Option, + family: Option, + weight: u16, + italic: bool, + } + fn weight(v: &Value) -> Option { + let num = v.as_u64().and_then(|v| u16::try_from(v).ok()).or_else(|| v.as_str().and_then(|s| s.parse::().ok())).or_else(|| { + match v.as_str()?.to_ascii_lowercase().as_str() { + "thin" => Some(100), + "extralight" | "ultralight" => Some(200), + "light" => Some(300), + "regular" | "normal" => Some(400), + "medium" => Some(500), + "semibold" | "demibold" => Some(600), + "bold" => Some(700), + "extrabold" | "ultrabold" => Some(800), + "black" | "heavy" => Some(900), + _ => None, + } + }); + num.filter(|n| (1..=1000).contains(n)) + } + fn visit( + node: &Value, + out: &mut BTreeMap<(String, String), BTreeSet<(u16, bool)>>, + depth: usize, + inherited: &FontContext, + provider_prefix: &str, + ) { + if depth > 32 || out.len() >= 16 { + return; + } + let mut current = inherited.clone(); + if let Some(id) = string(node, "fontId") { + current.id = Some(id.to_owned()); + } + if let Some(family) = string(node, "fontFamily") { + current.family = Some(family.to_owned()); + } + if let Some(w) = node.get("fontWeight").and_then(weight) { + current.weight = w; + } + if let Some(style) = string(node, "fontStyle") { + current.italic = style.eq_ignore_ascii_case("italic") || style.eq_ignore_ascii_case("oblique"); + } + if let Some(variant) = string(node, "fontVariantId") { + let variant = variant.to_ascii_lowercase(); + if node.get("fontWeight").is_none() { + let digits: String = variant.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(weight) = digits.parse::() + && (1..=1000).contains(&weight) + { + current.weight = weight; + } + } + current.italic = variant.contains("italic") || variant.contains("oblique"); + } + if let (Some(id), Some(family)) = (¤t.id, ¤t.family) + && id.starts_with(provider_prefix) + && id.len() <= 128 + && id.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') + && !family.is_empty() + && family.len() <= 128 + { + let variants = out.entry((id.clone(), family.clone())).or_default(); + if variants.len() < 8 { + variants.insert((current.weight, current.italic)); + } + } + // Parent paragraphs and child spans may declare different portions of the + // font reference. Walk both the shape's content root and its styled runs. + if let Some(content) = node.get("content") { + visit(content, out, depth + 1, ¤t, provider_prefix); + } + if let Some(children) = node.get("children").and_then(Value::as_array) { + for child in children.iter().take(1024) { + visit(child, out, depth + 1, ¤t, provider_prefix); + } + } + } + let mut refs = BTreeMap::new(); + visit(source, &mut refs, 0, &FontContext { id: None, family: None, weight: 400, italic: false }, provider_prefix); + refs.into_iter() + .map(|((id, family), variants)| { + serde_json::json!({ + "id": id, + "family": family, + "variants": variants.into_iter().map(|(weight, italic)| serde_json::json!({"weight": weight, "italic": italic})).collect::>() + }) + }) + .collect() +} + +/// Return common text paints only when every styled run agrees. +fn uniform_text_fills(text: &TextData) -> Option> { + let base = text.style.fills.as_ref(); + let mut runs: Vec<(usize, usize)> = text.runs.iter().map(|r| (r.start, r.end)).collect(); + runs.sort_unstable(); + let covered = runs.iter().try_fold(0, |at, &(start, end)| (start <= at).then_some(at.max(end))); + let covers_all = covered.is_some_and(|end| end >= text.characters.chars().count()); + let mut colors: Vec>> = text.runs.iter().map(|r| r.style.fills.as_ref().or(base)).collect(); + if !covers_all { + colors.push(base); + } + let first = (*colors.first()?)?; + colors.iter().all(|c| *c == Some(first)).then(|| first.clone()) +} + +/// Apply supported Penpot text properties over an inherited native style. +fn text_style(v: &Value, base: &TextStyle) -> TextStyle { + let mut style = base.clone(); + if let Some(family) = string(v, "fontFamily").or_else(|| string(v, "fontId")) { + style.font_family = if family == "sourcesanspro" { "Source Sans Pro".into() } else { family.chars().take(200).collect() }; + } + let size = number(v, "fontSize", style.font_size); + if size > 0.0 && size <= 1000.0 { + style.font_size = size; + } + // Penpot can specify the face only by `fontVariantId` (e.g. 700italic), + // including in a leaf that inherited its family from a parent paragraph. + let variant = string(v, "fontVariantId").unwrap_or("").to_ascii_lowercase(); + let variant_weight: String = variant.chars().take_while(|c| c.is_ascii_digit()).collect(); + let weight = v + .get("fontWeight") + .and_then(|w| w.as_str().map(str::to_owned).or_else(|| w.as_u64().map(|n| n.to_string()))) + .or_else(|| (!variant_weight.is_empty()).then_some(variant_weight)); + if let Some(weight) = weight { + style.font_style = match weight.to_ascii_lowercase().as_str() { + "100" | "thin" => "Thin", + "200" | "extralight" | "extra-light" => "Extra Light", + "300" | "light" => "Light", + "500" | "medium" => "Medium", + "600" | "semibold" | "semi-bold" => "Semi Bold", + "700" | "bold" => "Bold", + "800" | "extrabold" | "extra-bold" => "Extra Bold", + "900" | "black" => "Black", + _ => "Regular", + } + .into(); + } + let italic = variant.contains("italic") + || variant.contains("oblique") + || string(v, "fontStyle").is_some_and(|v| v.eq_ignore_ascii_case("italic") || v.eq_ignore_ascii_case("oblique")); + if italic && !style.font_style.contains("Italic") { + style.font_style.push_str(" Italic"); + } + match string(v, "textDecoration") { + Some("underline") => style.decoration = TextDecoration::Underline, + Some("line-through") | Some("strikethrough") => style.decoration = TextDecoration::Strikethrough, + _ => {} + } + style.case = match string(v, "textTransform") { + Some("uppercase") => TextCase::Upper, + Some("lowercase") => TextCase::Lower, + Some("capitalize") => TextCase::Title, + _ => style.case, + }; + if v.get("letterSpacing").is_some() { + style.letter_spacing = LetterSpacing::Pixels(number(v, "letterSpacing", 0.0)); + } + if v.get("lineHeight").is_some() { + let line = number(v, "lineHeight", 1.2); + if line > 0.0 { + style.line_height = LineHeight::Percent(line * 100.0); + } + } + let mut paints: Vec = list(v, "fills") + .filter_map(|fill| { + let mut p = match fill.get("fillColorGradient").and_then(gradient) { + Some(g) => Paint::gradient(g), + None => Paint::solid(string(fill, "fillColor").and_then(Color::from_hex)?), + }; + p.opacity *= number(fill, "fillOpacity", 1.0).clamp(0.0, 1.0) as f32; + Some(p) + }) + .take(32) + .collect(); + paints.reverse(); + if !paints.is_empty() { + style.fills = Some(paints); + } + style +} + +/// Penpot nests paragraphs, lines and character runs. Keep runs at Unicode character offsets; +/// bytes would truncate multibyte characters when the user edits the resulting FigCraft text. +fn text_node(v: &Value) -> TextData { + let mut fragments: Vec<(String, Value)> = Vec::new(); + fn own_style(v: &Value, inherited: &serde_json::Map) -> serde_json::Map { + // Text formatting can be declared on root, paragraph, span, or leaf. + // Inherit explicit properties down the tree rather than losing them if + // the actual text run only contains the `text` field. + let mut style = inherited.clone(); + for key in [ + "fontFamily", + "fontId", + "fontVariantId", + "fontSize", + "fontWeight", + "fontStyle", + "textDecoration", + "textTransform", + "letterSpacing", + "lineHeight", + "fills", + "textAlign", + ] { + if let Some(value) = v.get(key) { + style.insert(key.into(), value.clone()); + } + } + if v.get("fills").is_none() && (v.get("fillColor").is_some() || v.get("fillColorGradient").is_some()) { + let legacy: serde_json::Map = + ["fillColor", "fillOpacity", "fillColorGradient"].iter().filter_map(|k| Some(((*k).to_owned(), v.get(*k)?.clone()))).collect(); + style.insert("fills".into(), Value::Array(vec![Value::Object(legacy)])); + } + style + } + fn walk(v: &Value, result: &mut Vec<(String, Value)>, depth: usize, inherited: &serde_json::Map) { + if depth >= 64 || result.len() >= 10_000 { + return; + } + let style = own_style(v, inherited); + if let Some(t) = string(v, "text") { + result.push((t.chars().take(100_000).collect(), Value::Object(style.clone()))); + } + if string(v, "type") == Some("paragraph-set") { + let mut previous = None; + for child in list(v, "children") { + if let Some(ended) = previous.take() { + result.push(("\n".into(), Value::Object(ended))); + } + walk(child, result, depth + 1, &style); + previous = Some(own_style(child, &style)); + } + } else { + for child in list(v, "children") { + walk(child, result, depth + 1, &style); + } + } + } + let mut initial = serde_json::Map::new(); + if let Some(root) = v.get("content") { + for key in [ + "fontFamily", + "fontId", + "fontVariantId", + "fontSize", + "fontWeight", + "fontStyle", + "textDecoration", + "textTransform", + "letterSpacing", + "lineHeight", + "fills", + "textAlign", + ] { + if let Some(value) = v.get(key) { + initial.insert(key.into(), value.clone()); + } + } + for key in [ + "fontFamily", + "fontId", + "fontVariantId", + "fontSize", + "fontWeight", + "fontStyle", + "textDecoration", + "textTransform", + "letterSpacing", + "lineHeight", + "fills", + "textAlign", + ] { + if let Some(value) = root.get(key) { + initial.insert(key.into(), value.clone()); + } + } + walk(root, &mut fragments, 0, &initial); + } + let mut result = TextData::new(String::new()); + // The first run may contain formatting that applies *only* to that run. + // Deriving the base style from it would leak e.g. its Bold weight into + // unrelated siblings. Only the common root style becomes the base. + result.style = text_style(&Value::Object(initial), &result.style); + let root_style = fragments.first().map(|(_, s)| s.clone()).unwrap_or_else(|| v.clone()); + let mut current = 0usize; + for (piece, props) in fragments { + let size = piece.chars().count(); + if size == 0 || current.saturating_add(size) > 100_000 { + continue; + } + let st = text_style(&props, &result.style); + if st != result.style { + result.runs.push(StyleRun { start: current, end: current + size, style: st }); + } + result.characters.push_str(&piece); + current += size; + } + result.align_h = match string(&root_style, "textAlign") { + Some("center") => TextAlignH::Center, + Some("right") => TextAlignH::Right, + Some("justify") => TextAlignH::Justified, + _ => TextAlignH::Left, + }; + result.auto_resize = match string(v, "growType") { + Some("fixed") => TextAutoResize::None, + Some("auto-height") => TextAutoResize::Height, + _ => TextAutoResize::WidthAndHeight, + }; + result +} + +/// Approximate SVG/Penpot elliptical endpoint arcs with at most four editable cubic +/// Bézier segments. These are *native* FigCraft vector handles, not an SVG image. +/// Uses the W3C SVG endpoint-to-centre conversion and keeps all intermediate values finite. +fn arc_to_cubics(path: &mut BezPath, from: (f64, f64), to: (f64, f64), rx: f64, ry: f64, rotation_degrees: f64, large: bool, sweep: bool) -> bool { + use std::f64::consts::{FRAC_PI_2, PI}; + if ![from.0, from.1, to.0, to.1, rx, ry, rotation_degrees].iter().all(|n| n.is_finite()) { + return false; + } + let (mut rx, mut ry) = (rx.abs().min(1e6), ry.abs().min(1e6)); + if rx < 1e-9 || ry < 1e-9 { + path.line_to(to); + return true; + } + if (from.0 - to.0).abs() < 1e-9 && (from.1 - to.1).abs() < 1e-9 { + // Identical endpoints are a zero-length arc under the SVG spec. + return true; + } + let phi = rotation_degrees.rem_euclid(360.0).to_radians(); + let (sin, cos) = phi.sin_cos(); + let (dx, dy) = ((from.0 - to.0) * 0.5, (from.1 - to.1) * 0.5); + let (xp, yp) = (cos * dx + sin * dy, -sin * dx + cos * dy); + let lambda = (xp / rx).powi(2) + (yp / ry).powi(2); + if !lambda.is_finite() { + return false; + } + if lambda > 1.0 { + let scale = lambda.sqrt(); + rx *= scale; + ry *= scale; + } + let a = rx * rx * ry * ry; + let b = rx * rx * yp * yp + ry * ry * xp * xp; + if b <= 1e-24 || !a.is_finite() || !b.is_finite() { + return false; + } + let sign = if large == sweep { -1.0 } else { 1.0 }; + let k = sign * ((a - b).max(0.0) / b).sqrt(); + let (cxp, cyp) = (k * rx * yp / ry, -k * ry * xp / rx); + let cx = (from.0 + to.0) * 0.5 + cos * cxp - sin * cyp; + let cy = (from.1 + to.1) * 0.5 + sin * cxp + cos * cyp; + let (sx, sy) = ((xp - cxp) / rx, (yp - cyp) / ry); + let (ex, ey) = ((-xp - cxp) / rx, (-yp - cyp) / ry); + let start = sy.atan2(sx); + let mut delta = (sx * ey - sy * ex).atan2(sx * ex + sy * ey); + if sweep && delta < 0.0 { + delta += 2.0 * PI; + } + if !sweep && delta > 0.0 { + delta -= 2.0 * PI; + } + let segments = (delta.abs() / FRAC_PI_2).ceil().max(1.0) as usize; + if segments > 4 || ![cx, cy, start, delta].iter().all(|n| n.is_finite()) { + return false; + } + let step = delta / segments as f64; + let point = |angle: f64| { + let (s, c) = angle.sin_cos(); + (cx + rx * cos * c - ry * sin * s, cy + rx * sin * c + ry * cos * s) + }; + let tangent = |angle: f64| { + let (s, c) = angle.sin_cos(); + (-rx * cos * s - ry * sin * c, -rx * sin * s + ry * cos * c) + }; + let mut last = from; + for i in 0..segments { + let a0 = start + step * i as f64; + let a1 = a0 + step; + let k = (4.0 / 3.0) * (step / 4.0).tan(); + let t0 = tangent(a0); + let t1 = tangent(a1); + let end = if i + 1 == segments { to } else { point(a1) }; + let c1 = (last.0 + k * t0.0, last.1 + k * t0.1); + let c2 = (end.0 - k * t1.0, end.1 - k * t1.1); + if ![c1.0, c1.1, c2.0, c2.1, end.0, end.1].iter().all(|n| n.is_finite()) { + return false; + } + path.curve_to(c1, c2, end); + last = end; + } + true +} + +/// Read an SVG arc flag from its accepted boolean or numeric representation. +fn arc_flag(v: &Value, key: &str) -> bool { + match v.get(key) { + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_i64() == Some(1), + _ => false, + } +} + +/// Convert Penpot paths into editable Bézier networks. Commands are absolute page coordinates; +/// node geometry is local, so subtract the shape's x/y exactly once. +fn shape_matrix(v: &Value, x: f64, y: f64, w: f64, h: f64) -> Affine { + let flip = |key: &str| if v.get(key).and_then(Value::as_bool) == Some(true) { -1.0 } else { 1.0 }; + let fallback = Affine::rotate(-number(v, "rotation", 0.0).to_radians()) * Affine::scale_non_uniform(flip("flipX"), flip("flipY")); + let linear = v + .get("transform") + .map(|t| Affine::new([snap(number(t, "a", 1.0)), snap(number(t, "b", 0.0)), snap(number(t, "c", 0.0)), snap(number(t, "d", 1.0)), 0.0, 0.0])) + .filter(|m| m.determinant().abs() > 1e-9) + .unwrap_or(fallback); + Affine::translate((x + w / 2.0, y + h / 2.0)) * linear * Affine::translate((-w / 2.0, -h / 2.0)) +} + +/// Find a shape fill inherited from its ancestor metadata. +fn inherited_fill(shapes: &BTreeMap, v: &Value) -> Value { + let mut parent = string(v, "parentId"); + for _ in 0..MAX_DEPTH { + let Some(p) = parent.filter(|id| *id != ROOT_ID).and_then(|id| shapes.get(id)) else { break }; + match string(p, "type") { + Some("frame" | "bool") => break, + _ if list(p, "fills").next().is_some() => return serde_json::json!({"fills": list(p, "fills").take(1).collect::>()}), + Some("group" | "svg-raw") if p.get("svgAttrs").is_some() => return serde_json::json!({"fills": [{"fillColor": "#000000"}]}), + _ => parent = string(p, "parentId"), + } + } + serde_json::json!({"fills": []}) +} + +/// Convert a filled path into an editable vector network. +fn filled_network(path: &BezPath) -> VectorNetwork { + use figcraft_doc::geom::kurbo::PathEl; + let mut closed = BezPath::new(); + let mut synthetic_ids = BTreeSet::new(); + let mut segments = 0usize; + let mut started = false; + let mut open: Option<(figcraft_doc::geom::Point, figcraft_doc::geom::Point)> = None; + let mut close = + |closed: &mut BezPath, segments: &mut usize, open: Option<(figcraft_doc::geom::Point, figcraft_doc::geom::Point)>, synthetic: bool| { + let Some((start, end)) = open else { return }; + closed.close_path(); + if (end - start).hypot() > 1e-6 { + if synthetic { + synthetic_ids.insert(*segments); + } + *segments += 1; + } + }; + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + close(&mut closed, &mut segments, open.take(), true); + closed.move_to(p); + open = Some((p, p)); + started = true; + } + PathEl::ClosePath => close(&mut closed, &mut segments, open.take(), false), + other => { + closed.push(other); + if started { + segments += 1; + } + if let (Some((start, _)), Some(end)) = (open, other.end_point()) { + open = Some((start, end)); + } + } + } + } + close(&mut closed, &mut segments, open.take(), true); + let mut net = VectorNetwork::from_path(&closed); + let kept: Vec> = (0..net.segments.len()) + .scan(0usize, |next, i| { + Some(if synthetic_ids.contains(&i) { + None + } else { + *next += 1; + Some(*next - 1) + }) + }) + .collect(); + net.segments = net.segments.iter().enumerate().filter(|(i, _)| !synthetic_ids.contains(i)).map(|(_, s)| *s).collect(); + for region in &mut net.regions { + for lp in &mut region.loops { + *lp = lp.iter().filter_map(|i| kept.get(*i).copied().flatten()).collect(); + } + } + net +} + +/// Remove insignificant floating-point noise from transform coefficients. +fn snap(coefficient: f64) -> f64 { + let whole = coefficient.round(); + if (coefficient - whole).abs() < 1e-5 { whole } else { coefficient } +} + +#[cfg(test)] +/// Import vector commands into an editable native network. +fn vector_path(v: &Value, warnings: &mut Vec) -> Option { + vector_bezpath(v, warnings).map(|path| VectorNetwork::from_path(&path)) +} + +/// Parse supported vector commands and report unrepresentable geometry. +fn vector_bezpath(v: &Value, warnings: &mut Vec) -> Option { + if let Some(d) = v.get("content").and_then(Value::as_str) { + let path = BezPath::from_svg(d).ok().filter(|p| !p.elements().is_empty())?; + let origin = Affine::translate((-geometry(v, "x", 0.0), -geometry(v, "y", 0.0))); + return Some(origin * path); + } + let commands = v.get("content")?.as_array()?; + if commands.is_empty() || commands.len() > 100_000 { + return None; + } + let (ox, oy) = (geometry(v, "x", 0.0), geometry(v, "y", 0.0)); + let mut path = BezPath::new(); + let mut current = (0.0, 0.0); + let mut start = (0.0, 0.0); + let mut has_move = false; + let mut last_cubic: Option<(f64, f64)> = None; + let mut last_quad: Option<(f64, f64)> = None; + for item in commands { + let cmd = string(item, "command")?; + let args = item.get("params").unwrap_or(item); + let pt = (number(args, "x", current.0 + ox) - ox, number(args, "y", current.1 + oy) - oy); + match cmd { + "move-to" | "M" => { + path.move_to(pt); + current = pt; + start = pt; + has_move = true; + last_cubic = None; + last_quad = None; + } + "line-to" | "L" if has_move => { + path.line_to(pt); + current = pt; + last_cubic = None; + last_quad = None; + } + "line-to-horizontal" | "H" if has_move => { + let end = (pt.0, current.1); + path.line_to(end); + current = end; + last_cubic = None; + last_quad = None; + } + "line-to-vertical" | "V" if has_move => { + let end = (current.0, pt.1); + path.line_to(end); + current = end; + last_cubic = None; + last_quad = None; + } + "curve-to" | "C" if has_move => { + let c1 = (number(args, "c1x", ox) - ox, number(args, "c1y", oy) - oy); + let c2 = (number(args, "c2x", ox) - ox, number(args, "c2y", oy) - oy); + path.curve_to(c1, c2, pt); + current = pt; + last_cubic = Some(c2); + last_quad = None; + } + "smooth-curve-to" | "S" if has_move => { + let c1 = last_cubic.map(|p| (2.0 * current.0 - p.0, 2.0 * current.1 - p.1)).unwrap_or(current); + let c2 = (number(args, "c2x", ox) - ox, number(args, "c2y", oy) - oy); + path.curve_to(c1, c2, pt); + current = pt; + last_cubic = Some(c2); + last_quad = None; + } + "quadratic-bezier-curve-to" | "Q" if has_move => { + let c = (number(args, "c1x", ox) - ox, number(args, "c1y", oy) - oy); + path.quad_to(c, pt); + current = pt; + last_quad = Some(c); + last_cubic = None; + } + "smooth-quadratic-bezier-curve-to" | "T" if has_move => { + let c = last_quad.map(|p| (2.0 * current.0 - p.0, 2.0 * current.1 - p.1)).unwrap_or(current); + path.quad_to(c, pt); + current = pt; + last_quad = Some(c); + last_cubic = None; + } + "close-path" | "Z" if has_move => { + path.close_path(); + current = start; + last_cubic = None; + last_quad = None; + } + "elliptical-arc" | "A" if has_move => { + if !arc_to_cubics( + &mut path, + current, + pt, + number(args, "rx", 0.0), + number(args, "ry", 0.0), + number(args, "xAxisRotation", 0.0), + arc_flag(args, "largeArcFlag"), + arc_flag(args, "sweepFlag"), + ) { + warn(warnings, "Penpot ellipse arc has invalid parameters; retained as unconverted frame".into()); + return None; + } + current = pt; + last_cubic = None; + last_quad = None; + } + _ => { + warn(warnings, format!("Unsupported Penpot path command '{cmd}'; path left unconverted")); + return None; // Never silently drop segments (that would change the silhouette). + } + } + } + if !has_move { + return None; + } + Some(path) +} + +/// Import bounded shadow and blur effects, warning about unsupported blur types. +fn effects(v: &Value, warnings: &mut Vec) -> Vec { + let mut out = Vec::new(); + for item in list(v, "shadow").chain(list(v, "shadows")).take(32) { + let raw_color = item.get("color").unwrap_or(item); + let c = raw_color.as_str().and_then(Color::from_hex).or_else(|| string(raw_color, "color").and_then(Color::from_hex)).unwrap_or(Color::BLACK); + let opacity = number(raw_color, "opacity", number(item, "opacity", 1.0)); + let shadow = Shadow { + color: c.with_alpha((c.a as f64 * opacity).clamp(0.0, 1.0) as f32), + offset: ( + number(item, "offsetX", item.get("offset").map(|o| number(o, "x", 0.0)).unwrap_or(0.0)), + number(item, "offsetY", item.get("offset").map(|o| number(o, "y", 0.0)).unwrap_or(0.0)), + ), + radius: number(item, "blur", 0.0).clamp(0.0, 10000.0), + spread: number(item, "spread", 0.0).clamp(-10000.0, 10000.0), + ..Shadow::default() + }; + let inner = matches!(string(item, "style"), Some("inner-shadow" | "inner")); + out.push(Effect { + kind: if inner { EffectKind::InnerShadow(shadow) } else { EffectKind::DropShadow(shadow) }, + visible: item.get("hidden").and_then(Value::as_bool) != Some(true), + }); + } + for blur in [v.get("blur"), v.get("backgroundBlur")].into_iter().flatten() { + let mode = string(blur, "type").unwrap_or("layer-blur"); + let radius = number(blur, "value", 0.0).clamp(0.0, 1000.0); + let kind = match mode { + "layer-blur" | "layer" => Some(EffectKind::LayerBlur(Blur::uniform(radius))), + "background-blur" | "background" => Some(EffectKind::BackgroundBlur(Blur::uniform(radius))), + _ => None, + }; + if let Some(kind) = kind { + out.push(Effect { kind, visible: blur.get("hidden").and_then(Value::as_bool) != Some(true) }); + } else { + warn(warnings, format!("Unsupported Penpot blur type '{mode}'")); + } + } + out +} + +/// Resolve a Penpot image fill through `media/.json` -> `objects/.png|jpg`. +fn paints( + z: &mut ZipArchive>, + base: &str, + shape: &Value, + key: &str, + doc: &mut Document, + images: &mut BTreeMap, + image_bytes: &mut usize, + budget: &mut InflateBudget, + warnings: &mut Vec, +) -> Vec { + let mut out = Vec::new(); + for fill in list(shape, key).take(50) { + let stroke = key == "strokes"; + let opacity = if stroke { number(fill, "strokeOpacity", number(fill, "fillOpacity", 1.0)) } else { number(fill, "fillOpacity", 1.0) } + .clamp(0.0, 1.0) as f32; + if let Some(image) = fill.get(if stroke { "strokeImage" } else { "fillImage" }).or_else(|| if stroke { fill.get("fillImage") } else { None }) + { + let Some(id) = string(image, "id").filter(|id| uuid(id)) else { + warn(warnings, "Invalid image fill identifier".into()); + continue; + }; + let resolved = match images.get(id) { + Some(hash) => Some(hash.clone()), + None => { + let meta_path = format!("{base}/media/{id}.json"); + let meta = json(z, &meta_path, budget).ok(); + let object_id = meta.as_ref().and_then(|m| string(m, "mediaId")).filter(|oid| uuid(oid)); + let extension = match meta.as_ref().and_then(|m| string(m, "mtype")) { + Some("image/jpeg") => "jpg", + Some("image/webp") => "webp", + Some("image/png") => "png", + _ => "unknown", + }; + if let Some(obj) = object_id { + let p = format!("objects/{obj}.{extension}"); + let data = z.by_name(&p).ok().and_then(|f| { + if f.size() > MAX_IMAGE { + return None; + } + let mut bytes = Vec::new(); + BudgetReader { inner: f, budget: &mut *budget }.take(MAX_IMAGE + 1).read_to_end(&mut bytes).ok()?; + if bytes.len() as u64 > MAX_IMAGE || bytes.len() > MAX_TOTAL_IMAGES.saturating_sub(*image_bytes) { + return None; + } + Some(bytes) + }); + data.and_then(|b| { + let size = b.len(); + let result = add_image(doc, b).ok().map(|(hash, _, _)| hash); + if result.is_some() { + *image_bytes += size; + } + result + }) + } else { + None + } + } + }; + if let Some(hash) = resolved { + images.insert(id.to_owned(), hash.clone()); + // A Penpot image with keepAspectRatio=false stretches the full source + // rectangle into the layer. FigCraft's Crop mode with identity UV mapping + // is the native, editable representation (Fill would crop the edges). + let stretch = image.get("keepAspectRatio").and_then(Value::as_bool) == Some(false); + let mut p = Paint::image(hash, if stretch { ImageScaleMode::Crop } else { ImageScaleMode::Fill }); + if stretch && let PaintKind::Image(ref mut img) = p.kind { + img.crop = Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + } + p.opacity = opacity; + p.visible = fill.get("hidden").and_then(Value::as_bool) != Some(true); + out.push(p); + } else { + warn(warnings, format!("Missing or unsupported Penpot image {id}")); + } + } else if let Some(raw) = fill.get(if stroke { "strokeColorGradient" } else { "fillColorGradient" }).or_else(|| fill.get("gradient")) { + if let Some(g) = gradient(raw) { + let mut p = Paint::gradient(g); + p.opacity = opacity; + p.visible = fill.get("hidden").and_then(Value::as_bool) != Some(true); + out.push(p); + } else { + warn(warnings, "Unrecognized Penpot gradient".into()); + } + } else if let Some(hex) = + string(fill, if stroke { "strokeColor" } else { "fillColor" }).or_else(|| if stroke { string(fill, "fillColor") } else { None }) + && let Some(c) = Color::from_hex(hex) + { + let mut p = Paint::solid(c); + p.opacity *= opacity; + p.visible = fill.get("hidden").and_then(Value::as_bool) != Some(true); + out.push(p); + } + } + out.reverse(); + out +} + +/// Penpot gradient handles are unit-box positions. The `width` factor defines the perpendicular +/// radius of a radial gradient; FigCraft stores that as a perpendicular handle position. +fn gradient(v: &Value) -> Option { + let kind = match string(v, "type")? { + "linear" => GradientKind::Linear, + "radial" => GradientKind::Radial, + _ => return None, + }; + let stops = list(v, "stops") + .take(128) + .filter_map(|s| { + let color = string(s, "color").and_then(Color::from_hex)?; + Some(ColorStop { + position: number(s, "offset", 0.0).clamp(0.0, 1.0) as f32, + color: color.with_alpha((color.a as f64 * number(s, "opacity", 1.0).clamp(0.0, 1.0)) as f32), + }) + }) + .collect::>(); + if stops.is_empty() { + return None; + } + let from = (number(v, "startX", 0.0), number(v, "startY", 0.0)); + let to = (number(v, "endX", 1.0), number(v, "endY", 0.0)); + let dx = to.0 - from.0; + let dy = to.1 - from.1; + let radius = number(v, "width", 1.0).clamp(0.0, 1000.0); + let mut g = Gradient { kind, stops, from, to, width: (from.0 - dy * radius, from.1 + dx * radius) }; + if kind == GradientKind::Linear { + g.square_linear_width(1.0, 1.0); + } + Some(g) +} + +#[cfg(test)] +mod google_font_ref_tests { + use super::*; + #[test] + fn nested_runs_inherit_the_google_font_reference_and_weight() { + let source = serde_json::json!({"type":"text", "fontId":"gfont-open-sans", + "content":{"type":"paragraph-set", "fontFamily":"Open Sans", "children":[ + {"type":"paragraph", "fontWeight":700, "children":[ + {"text":"Bold"}, {"text":" Italic", "fontVariantId":"700italic"} + ]} + ]} + }); + let refs = referenced_google_fonts(&source); + assert_eq!(refs.len(), 1); + let variants = refs[0]["variants"].as_array().unwrap(); + assert!(variants.contains(&serde_json::json!({"weight":700,"italic":false}))); + assert!(variants.contains(&serde_json::json!({"weight":700,"italic":true}))); + } + + #[test] + fn variant_id_supplies_weight_when_weight_is_not_explicit() { + let source = serde_json::json!({"content":{"fontId":"gfont-open-sans", "fontFamily":"Open Sans", "fontVariantId":"700italic", "text":"Hi"}}); + let refs = referenced_google_fonts(&source); + assert!(refs[0]["variants"].as_array().unwrap().contains(&serde_json::json!({"weight":700,"italic":true}))); + } + + #[test] + fn text_runs_use_variant_id_for_the_rendered_face() { + let source = serde_json::json!({"content":{"type":"root", "children":[ + {"text":"Example", "fontId":"gfont-open-sans", "fontFamily":"Open Sans", "fontVariantId":"700italic"} + ]}}); + let text = text_node(&source); + assert_eq!(text.style_at(0).font_family, "Open Sans"); + assert_eq!(text.style_at(0).font_style, "Bold Italic"); + } + + #[test] + fn named_font_weight_is_recorded_for_google_fonts() { + let source = serde_json::json!({"content": {"fontId":"gfont-roboto", "fontFamily":"Roboto", "fontWeight":"bold", "text":"Hi"}}); + let refs = referenced_google_fonts(&source); + assert!(refs[0]["variants"].as_array().unwrap().contains(&serde_json::json!({"weight":700,"italic":false}))); + } + + #[test] + fn preserves_google_font_id_and_family_from_nested_text_runs() { + let layer = serde_json::json!({"content":{"type":"root","children":[ + {"type":"paragraph-set","children":[{"type":"paragraph","fontId":"gfont-spicy-rice", "fontFamily":"Spicy Rice", + "children":[{"fontId":"gfont-spicy-rice","fontFamily":"Spicy Rice","text":"demo"}]}]} + ]}}); + let refs = referenced_google_fonts(&layer); + assert_eq!(refs, vec![serde_json::json!({"id":"gfont-spicy-rice","family":"Spicy Rice", "variants":[{"weight":400,"italic":false}]})]); + } + #[test] + fn preserves_multiple_weights_and_italic_variants_in_text_runs() { + let layer = serde_json::json!({"content":{"children":[ + {"fontId":"gfont-open-sans","fontFamily":"Open Sans", "fontWeight":"700", "fontVariantId":"700italic"}, + {"fontId":"gfont-open-sans","fontFamily":"Open Sans", "fontWeight":400, "fontVariantId":"regular"} + ]}}); + let refs = referenced_google_fonts(&layer); + assert_eq!( + refs, + vec![serde_json::json!({"id":"gfont-open-sans","family":"Open Sans", "variants":[ + {"weight":400,"italic":false}, {"weight":700,"italic":true} + ]})] + ); + } + + #[test] + fn preserves_custom_font_id_separately_from_google_provider() { + let source = serde_json::json!({"content":{"children":[ + {"text":"Playball", "fontId":"custom-407a1221-a42c-80cc-8008-cd8ae87156cc", "fontFamily":"Playball", "fontVariantId":"normal-400"}, + {"text":"Playball", "fontId":"gfont-playball", "fontFamily":"Playball", "fontVariantId":"regular"} + ]}}); + let custom = referenced_custom_fonts(&source); + assert_eq!(custom.len(), 1); + assert_eq!(custom[0]["id"], "custom-407a1221-a42c-80cc-8008-cd8ae87156cc"); + assert_eq!(custom[0]["variants"][0]["weight"], 400); + assert_eq!(referenced_google_fonts(&source).len(), 1); + } + + #[test] + fn ignores_untrusted_or_unresolvable_font_ids() { + let v = serde_json::json!({"content":{"children":[ + {"fontId":"custom-team-font", "fontFamily":"Spicy Rice"}, + {"fontId":"gfont-../bad", "fontFamily":"Spicy Rice"}, + {"fontId":"gfont-spicy-rice", "fontFamily":"Spicy Rice"} + ]}}); + assert_eq!(referenced_google_fonts(&v).len(), 1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inflate_budget_counts_bytes_read_even_when_headers_claim_less() { + // Two independent readers share the same actual output limit. + let mut budget = InflateBudget { remaining: 5, ..InflateBudget::new() }; + let mut first = Vec::new(); + BudgetReader { inner: Cursor::new(b"abcd"), budget: &mut budget }.read_to_end(&mut first).unwrap(); + assert_eq!(first, b"abcd"); + assert_eq!(budget.remaining, 1); + + let mut second = Vec::new(); + let result = BudgetReader { inner: Cursor::new(b"xyz"), budget: &mut budget }.read_to_end(&mut second); + assert!(result.is_err(), "bytes beyond the combined budget must be rejected"); + assert_eq!(budget.remaining, 0); + assert!(budget.check().is_err()); + } + + #[test] + fn json_value_budget_is_shared_by_shape_and_other_members() { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + for name in ["tokens.json", "shape.json"] { + writer.start_file(name, SimpleFileOptions::default()).unwrap(); + writer.write_all(b"[0,0]").unwrap(); + } + let bytes = writer.finish().unwrap().into_inner(); + let mut archive = ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + let mut budget = InflateBudget { json_values: 6, ..InflateBudget::new() }; + let mut page_bytes = MAX_PAGE_SHAPES_BYTES; + assert_eq!(json(&mut archive, "tokens.json", &mut budget).unwrap(), serde_json::json!([0, 0])); + assert_eq!(shape_json(&mut archive, "shape.json", &mut budget, &mut page_bytes).unwrap(), serde_json::json!([0, 0])); + assert_eq!(budget.json_values, 0); + assert!(budget.check().is_ok(), "an exact-budget import is allowed"); + assert!(json(&mut archive, "tokens.json", &mut budget).is_err()); + assert!(budget.check().is_err(), "optional-member callers cannot swallow exhaustion"); + } + + #[test] + fn json_value_budget_rejects_dense_input_before_deserialization() { + // The malformed tail would cause a serde syntax error after allocating the array. + // Admission must reject it for structure first, while the input is still only bytes. + let dense = "[".to_owned() + &"0,".repeat(10_000); + let mut budget = InflateBudget { json_values: 100, ..InflateBudget::new() }; + let err = budget.parse_json(dense.as_bytes(), "shape.json").unwrap_err(); + assert!(err.to_string().contains("value budget"), "{err}"); + assert!(budget.check().is_err()); + } + + #[test] + fn json_value_budget_handles_strings_escapes_and_empty_input() { + for text in ["[{,:", "\\", "\\\"[{,:", "é"] { + let value = Value::String(text.into()); + let mut budget = InflateBudget { json_values: 1, ..InflateBudget::new() }; + assert_eq!(budget.parse_json(&serde_json::to_vec(&value).unwrap(), "shape.json").unwrap(), value); + assert!(budget.check().is_ok()); + } + // An escaped backslash before a closing quote must not hide later containers. + let mut budget = InflateBudget { json_values: 3, ..InflateBudget::new() }; + assert!(budget.parse_json(br#"["\\",[0,0]]"#, "shape.json").is_err()); + assert!(budget.check().is_err()); + let mut budget = InflateBudget { json_values: 0, ..InflateBudget::new() }; + assert!(budget.parse_json(b"", "shape.json").is_err()); + assert!(budget.check().is_err()); + } + + #[test] + fn import_rejects_a_dense_shape_within_the_zip_byte_limits() { + let id = "11111111-1111-1111-1111-111111111111"; + let bytes = archive_with_shapes(&[(id, serde_json::json!({"id":id,"type":"rect"}))]); + let mut source = ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let dense = format!(r#"{{"id":"{id}","type":"rect","unused":[{}0]}}"#, "0,".repeat(MAX_JSON_VALUES)); + assert!(dense.len() < MAX_JSON as usize); + for index in 0..source.len() { + let mut member = source.by_index(index).unwrap(); + writer.start_file(member.name(), crate::json_options()).unwrap(); + if member.name().ends_with(&format!("/{id}.json")) { + writer.write_all(dense.as_bytes()).unwrap(); + } else { + io::copy(&mut member, &mut writer).unwrap(); + } + } + let bytes = writer.finish().unwrap().into_inner(); + assert!(bytes.len() < MAX_ARCHIVE); + let err = import(&bytes).unwrap_err(); + assert!(err.to_string().contains("value budget"), "{err}"); + } + + #[test] + fn indexed_token_preserves_set_priority_and_charges_probes() { + let original = serde_json::json!({"$type":"number", "$value":1}); + let latest = serde_json::json!({"$type":"number", "$value":3}); + let paths = BTreeMap::from([("size", vec![(0, &original), (2, &latest)])]); + let mut budget = TokenResolutionBudget { remaining: 2 }; + assert_eq!(indexed_token("size", &BTreeSet::from([0]), &paths, &mut budget), Some(&original)); + assert_eq!(budget.remaining, 0, "both candidates must consume work"); + assert_eq!(indexed_token("size", &BTreeSet::from([2]), &paths, &mut budget), None); + } + + #[test] + fn token_number_charges_each_missing_set_in_linear_lookup() { + let number = serde_json::json!({"$type":"number", "$value":7}); + let tokens = BTreeMap::from([("A", BTreeMap::from([("size".to_owned(), &number)])), ("B", BTreeMap::new()), ("C", BTreeMap::new())]); + let mut small = TokenResolutionBudget { remaining: 3 }; + assert_eq!(token_number("size", &["A", "B", "C"], &tokens, 0, 0, &mut small), None); + let mut enough = TokenResolutionBudget { remaining: 4 }; + assert_eq!(token_number("size", &["A", "B", "C"], &tokens, 0, 0, &mut enough), Some(7.0)); + } + + #[test] + fn grid_cells_indexes_child_shapes_to_their_cell() { + let parent = serde_json::json!({ + "layoutGridCells": { + "cell-1": {"row": 1, "col": 1, "shapes": ["child-a", "child-b"]}, + "cell-2": {"row": 1, "col": 2, "shapes": ["child-c"]} + } + }); + let cells = grid_cells(&parent); + assert_eq!(cells.get("child-a").and_then(|c| c.get("row")).and_then(Value::as_u64), Some(1)); + assert_eq!(cells.get("child-b").and_then(|c| c.get("col")).and_then(Value::as_u64), Some(1)); + assert_eq!(cells.get("child-c").and_then(|c| c.get("col")).and_then(Value::as_u64), Some(2)); + assert_eq!(cells.get("child-unknown"), None); + + let non_grid = serde_json::json!({"type": "frame"}); + assert!(grid_cells(&non_grid).is_empty()); + } + + #[test] + fn page_shape_budget_rejects_shapes_exceeding_byte_limit() { + let buffer = Cursor::new(Vec::new()); + let mut z = zip::ZipWriter::new(buffer); + z.start_file("shape.json", SimpleFileOptions::default()).unwrap(); + z.write_all(br#"{"id":"00000000-0000-0000-0000-000000000001","type":"rect"}"#).unwrap(); + let bytes = z.finish().unwrap().into_inner(); + let mut archive = ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + + let mut inflate_budget = InflateBudget::new(); + let mut page_budget = 10u64; // Too small for the shape JSON (~60 bytes) + let res = shape_json(&mut archive, "shape.json", &mut inflate_budget, &mut page_budget); + assert!(res.is_err()); + + let mut archive2 = ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + let mut inflate_budget2 = InflateBudget::new(); + let mut page_budget2 = 1000u64; + let res2 = shape_json(&mut archive2, "shape.json", &mut inflate_budget2, &mut page_budget2); + assert!(res2.is_ok()); + assert!(page_budget2 < 1000); + } + use std::io::Write; + use zip::write::SimpleFileOptions; + + fn archive_with_shapes(shapes: &[(&str, Value)]) -> Vec { + archive_with_components(shapes, &[]) + } + + fn archive_with_components(shapes: &[(&str, Value)], components: &[(&str, Value)]) -> Vec { + archive_with_page(shapes, components, Value::Null) + } + + /// The same archive, with the page's own JSON (its `flows` among them). + fn archive_with_page(shapes: &[(&str, Value)], components: &[(&str, Value)], page_json: Value) -> Vec { + let file = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let page = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + let ids = shapes + .iter() + .filter(|(_, shape)| string(shape, "parentId").is_none_or(|parent| parent == ROOT_ID)) + .map(|(id, _)| Value::String((*id).into())) + .collect::>(); + let buffer = Cursor::new(Vec::new()); + let mut z = zip::ZipWriter::new(buffer); + let mut entries = vec![ + ("manifest.json".into(), serde_json::json!({ "type": "penpot/export-files", "version": 1, "files": [{"id": file, "name": "Advanced"}]})), + ( + format!("files/{file}/pages/{page}.json"), + if page_json.is_null() { serde_json::json!({"id":page,"name":"Canvas","index":0}) } else { page_json }, + ), + (format!("files/{file}/pages/{page}/{ROOT_ID}.json"), serde_json::json!({"id":ROOT_ID,"type":"frame","shapes":ids})), + ]; + entries.extend(shapes.iter().map(|(id, value)| (format!("files/{file}/pages/{page}/{id}.json"), value.clone()))); + entries.extend(components.iter().map(|(id, value)| (format!("files/{file}/components/{id}.json"), value.clone()))); + for (name, data) in entries { + z.start_file(name, SimpleFileOptions::default()).unwrap(); + z.write_all(data.to_string().as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() + } + + #[test] + fn raw_svg_from_export_is_preserved_for_native_svg_expansion() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let raw = r#""#; + let layer = serde_json::json!({"id":id,"type":"svg-raw","name":"Vector ornament","width":40,"height":25, + "svgAttrs":{"raw":raw}}); + let (doc, issues) = import(&archive_with_shapes(&[(id, layer)])).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + let node = doc.nodes().find(|n| n.name == "Vector ornament").unwrap(); + assert_eq!(node.plugin_data.get("penpot.svgRawXml").map(String::as_str), Some(raw)); + assert_eq!(node.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("pending")); + } + + #[test] + fn raw_svg_json_tree_escapes_attribute_values_and_text() { + let shape = serde_json::json!({"type":"svg-raw","width":80,"height":40, + "content":{"tag":"g","attrs":{"id":"A&B","onclick":"bad()"}, + "content":[{"tag":"text","content":"Hello & more"}]}}); + let xml = raw_svg_xml(&shape).unwrap(); + assert!(xml.starts_with("{}", "x".repeat(131072))}}); + assert!(raw_svg_xml(&oversized).is_none()); + let malformed = serde_json::json!({"content":{"tag":"x>>(); + assert_eq!(names, ["Mask", "Content"]); + assert!(doc.node(group.children[0]).unwrap().is_mask); + assert_eq!(group.plugin_data.get("penpot.maskImported").map(String::as_str), Some("true")); + assert!(doc.validate().is_empty()); + } + + #[test] + fn masked_group_uses_its_first_layer_as_the_mask_like_penpot() { + let group = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let mask = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let image = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let flagged = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + let shapes = [ + (group, serde_json::json!({"id":group,"name":"Card","type":"group","maskedGroup":true,"shapes":[mask, image, flagged]})), + (mask, serde_json::json!({"id":mask,"name":"cover-mask","type":"rect","parentId":group})), + (image, serde_json::json!({"id":image,"name":"Image","type":"rect","parentId":group})), + (flagged, serde_json::json!({"id":flagged,"name":"Flagged","type":"rect","mask":true,"parentId":group})), + ]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + assert!(!warnings.iter().any(|n| n.contains("mask")), "{warnings:?}"); + let group = doc.nodes().find(|n| n.name == "Card").unwrap(); + let layers = group.children.iter().map(|&id| doc.node(id).unwrap()).map(|n| (n.name.as_str(), n.is_mask)).collect::>(); + assert_eq!(layers, [("cover-mask", true), ("Image", false), ("Flagged", false)]); + assert!(doc.validate().is_empty()); + } + + #[test] + fn masked_group_without_layers_is_preserved_with_a_note() { + let group = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let shapes = [(group, serde_json::json!({"id":group,"name":"Empty","type":"group","maskedGroup":true,"shapes":[]}))]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + assert!(doc.nodes().any(|n| n.name == "Empty")); + assert!(warnings.iter().any(|n| n.contains("no first layer"))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn exported_pages_and_color_library_survive_native_import() { + let file = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let page = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + let color = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let buffer = Cursor::new(Vec::new()); + let mut zip = zip::ZipWriter::new(buffer); + let entries = [ + ( + "manifest.json".to_owned(), + serde_json::json!({"type":"penpot/export-files","version":1,"files":[{"id":file,"name":"Colors and guides"}]}), + ), + ( + format!("files/{file}/pages/{page}.json"), + serde_json::json!({"id":page,"name":"Canvas","index":0,"options":{"guides":{"guide-1":{"axis":"x","position":100}}}}), + ), + (format!("files/{file}/pages/{page}/{ROOT_ID}.json"), serde_json::json!({"id":ROOT_ID,"type":"frame","shapes":[]})), + (format!("files/{file}/colors/{color}.json"), serde_json::json!({"id":color,"name":"Accent","color":"#DEADBE","opacity":0.5})), + ]; + for (name, value) in entries { + zip.start_file(name, SimpleFileOptions::default()).unwrap(); + zip.write_all(value.to_string().as_bytes()).unwrap(); + } + let (doc, warnings) = import(&zip.finish().unwrap().into_inner()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(doc.document_colors.len(), 1); + assert!((doc.document_colors[0].a - 0.5).abs() < 0.01); + let page = doc.node(doc.pages[0]).unwrap(); + assert!(matches!(&page.kind, NodeKind::Page { guides, .. } if guides == &vec![Guide {vertical: true, offset:100.0}])); + assert!(doc.validate().is_empty()); + } + + #[test] + fn stretched_image_from_a_penpot_archive_is_editable_without_cropping() { + const PNG: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0, 144, 119, 83, 222, 0, 0, 0, 12, 73, + 68, 65, 84, 120, 156, 99, 8, 168, 88, 0, 0, 2, 132, 1, 105, 50, 45, 167, 126, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + let file = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let page = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + let shape = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let media = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let object = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let entries: Vec<(String, Vec)> = vec![ + ( + "manifest.json".into(), + serde_json::to_vec(&serde_json::json!({ + "type":"penpot/export-files", "version":1, "files":[{"id":file,"name":"image test"}] + })) + .unwrap(), + ), + ( + format!("files/{file}/pages/{page}.json"), + serde_json::to_vec(&serde_json::json!({ + "id":page,"name":"Canvas","index":0 + })) + .unwrap(), + ), + ( + format!("files/{file}/pages/{page}/{ROOT_ID}.json"), + serde_json::to_vec(&serde_json::json!({ + "id":ROOT_ID,"type":"frame","shapes":[shape] + })) + .unwrap(), + ), + ( + format!("files/{file}/pages/{page}/{shape}.json"), + serde_json::to_vec(&serde_json::json!({ + "id":shape,"type":"image","name":"Stretched","width":100,"height":100, + "metadata":{"id":media,"keepAspectRatio":false} + })) + .unwrap(), + ), + ( + format!("files/{file}/media/{media}.json"), + serde_json::to_vec(&serde_json::json!({ + "id":media,"mtype":"image/png","mediaId":object + })) + .unwrap(), + ), + (format!("objects/{object}.png"), PNG.to_vec()), + ]; + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, data) in entries { + zip.start_file(name, SimpleFileOptions::default()).unwrap(); + zip.write_all(&data).unwrap(); + } + let bytes = zip.finish().unwrap().into_inner(); + let (doc, warnings) = import(&bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(doc.images.len(), 1); + let node = doc.nodes().find(|n| n.name == "Stretched").unwrap(); + assert!(matches!(node.fills.first().map(|p| &p.kind), Some(PaintKind::Image(img)) + if img.scale_mode == ImageScaleMode::Crop && img.crop == Some([1.0,0.0,0.0,1.0,0.0,0.0]))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn crop_identity_is_the_native_equivalent_of_stretched_penpot_image() { + // FigCraft Crop maps unit coordinates into image UV. The identity transform + // stretches the full image to the node regardless of aspect ratio. + let mut p = Paint::image("source", ImageScaleMode::Crop); + if let PaintKind::Image(ref mut img) = p.kind { + img.crop = Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + assert_eq!(img.effective_crop(640.0, 320.0, 100.0, 100.0), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + } else { + panic!("expected an image paint"); + } + } + + #[test] + fn penpot_svg_arcs_become_native_editable_bezier_segments() { + let mut warnings = Vec::new(); + let path = serde_json::json!({ + "x": 10, "y": 20, + "content": [ + {"command": "M", "params": {"x": 10, "y": 20}}, + {"command": "A", "params": { + "x": 110, "y": 120, "rx": 100, "ry": 100, + "xAxisRotation": 0, "largeArcFlag": false, "sweepFlag": true + }} + ] + }); + let net = vector_path(&path, &mut warnings).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(!net.segments.is_empty()); + assert!(net.segments.iter().any(|seg| !seg.is_straight())); + let end = net.vertices.last().unwrap(); + assert!((end.x - 100.0).abs() < 1e-7); + assert!((end.y - 100.0).abs() < 1e-7); + } + + #[test] + fn penpot_arc_handles_rotated_ellipse_and_invalid_zero_radius() { + let mut warnings = Vec::new(); + let path = serde_json::json!({"content": [ + {"command": "move-to", "params": {"x": 10, "y": 10}}, + {"command": "elliptical-arc", "params": { + "x": 120, "y": 80, "rx": 40, "ry": 15, + "xAxisRotation": 38, "largeArcFlag": 1, "sweepFlag": 0 + }}, + {"command": "A", "params": {"x": 150, "y": 80, "rx": 0, "ry": 12}} + ]}); + let net = vector_path(&path, &mut warnings).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(net.segments.iter().any(|s| !s.is_straight())); + assert!(net.segments.iter().any(|s| s.is_straight())); + assert!((net.vertices.last().unwrap().x - 150.0).abs() < 1e-7); + } + + #[test] + fn omitted_nested_shape_list_recovers_child_from_parent_id() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let child = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let root = serde_json::json!({ + "id": frame, "name": "Container", "type": "frame", "width": 200, "height": 120, + "parentId": ROOT_ID, "shapes": [] + }); + let leaf = serde_json::json!({ + "id": child, "name": "Unlisted child", "type": "rect", "parentId": frame, + "x": 12, "y": 24, "width": 20, "height": 30 + }); + let (doc, warnings) = import(&archive_with_shapes(&[(frame, root), (child, leaf)])).unwrap(); + let parent = doc.nodes().find(|n| n.name == "Container").unwrap(); + let child = doc.nodes().find(|n| n.name == "Unlisted child").unwrap(); + assert_eq!(child.parent, Some(parent.id)); + assert!(parent.children.contains(&child.id)); + assert!(warnings.iter().any(|w| w.contains("omitted a child"))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn root_stacking_list_does_not_lose_unlisted_top_level_layers() { + let one = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let two = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let rect_a = serde_json::json!({"id":one,"name":"A","type":"rect","parentId":ROOT_ID}); + let rect_b = serde_json::json!({"id":two,"name":"B","type":"rect","parentId":ROOT_ID}); + let mut bytes = archive_with_shapes(&[(one, rect_a), (two, rect_b)]); + // Build a slightly damaged but valid ZIP whose root names only one of two siblings. + // Start from a fixture to keep all other structural fields in the original format. + let mut z = ZipArchive::new(Cursor::new(&bytes)).unwrap(); + let mut members = Vec::new(); + for i in 0..z.len() { + let mut e = z.by_index(i).unwrap(); + let mut content = Vec::new(); + e.read_to_end(&mut content).unwrap(); + if e.name().ends_with(&format!("/{ROOT_ID}.json")) { + let mut v: Value = serde_json::from_slice(&content).unwrap(); + v["shapes"] = serde_json::json!([one]); + content = serde_json::to_vec(&v).unwrap(); + } + members.push((e.name().to_string(), content)); + } + drop(z); + let cursor = Cursor::new(Vec::new()); + let mut out = zip::ZipWriter::new(cursor); + for (name, data) in members { + out.start_file(name, SimpleFileOptions::default()).unwrap(); + out.write_all(&data).unwrap(); + } + bytes = out.finish().unwrap().into_inner(); + let (doc, warnings) = import(&bytes).unwrap(); + assert!(doc.nodes().any(|n| n.name == "A")); + assert!(doc.nodes().any(|n| n.name == "B")); + assert!(warnings.iter().any(|w| w.contains("top-level child"))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn flex_layout_and_item_sizing_import_as_native_layout() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let child = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let f = serde_json::json!({ + "id":frame, "type":"frame", "name":"Flex board", "x":100, "y":200, + "width":500, "height":200, "shapes":[child], "layout":"flex", + "layoutFlexDir":"row", "layoutGap":{"columnGap":12,"rowGap":7}, + "layoutPadding":{"p1":2,"p2":3,"p3":4,"p4":5}, + "layoutJustifyContent":"space-between", "layoutAlignItems":"center", + "layoutWrapType":"wrap" + }); + let c = serde_json::json!({ + "id":child, "type":"rect", "name":"Flexible", "parentId":frame, + "x":120,"y":230,"width":200,"height":80, + "layoutItemHSizing":"fill", "layoutItemVSizing":"auto", + "layoutItemMinW":100,"layoutItemAbsolute":false + }); + let (doc, warnings) = import(&archive_with_shapes(&[(frame, f), (child, c)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let f = doc.nodes().find(|n| n.name == "Flex board").unwrap(); + let layout = f.layout.as_ref().unwrap(); + assert_eq!(layout.mode, LayoutMode::Horizontal); + assert_eq!(layout.gap, 12.0); + assert_eq!(layout.cross_gap, 7.0); + assert_eq!(layout.padding, [2.0, 3.0, 4.0, 5.0]); + assert_eq!(layout.main_align, MainAlign::Min); + assert_eq!(layout.cross_align, CrossAlign::Center); + assert!(layout.wrap); + let c = doc.nodes().find(|n| n.name == "Flexible").unwrap(); + assert_eq!(c.child_layout.sizing_h, Sizing::Fill); + assert_eq!(c.child_layout.sizing_v, Sizing::Hug); + assert_eq!(c.child_layout.min_w, Some(100.0)); + assert!(doc.validate().is_empty()); + } + + #[test] + fn space_between_is_kept_only_while_no_child_grows() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let child = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let row = |sizing: &str| { + let f = serde_json::json!({"id":frame,"type":"frame","name":"Row","width":300,"height":50,"shapes":[child], + "layout":"flex","layoutFlexDir":"row","layoutJustifyContent":"space-between"}); + let c = serde_json::json!({"id":child,"parentId":frame,"type":"rect","name":"Item","width":50,"height":50,"layoutItemHSizing":sizing}); + let (doc, _) = import(&archive_with_shapes(&[(frame, f), (child, c)])).unwrap(); + doc.nodes().find(|n| n.name == "Row").unwrap().layout.as_ref().unwrap().main_align + }; + assert_eq!(row("fix"), MainAlign::SpaceBetween); + assert_eq!(row("fill"), MainAlign::Min); + } + + #[test] + fn stretched_auto_grid_tracks_share_free_space_like_css() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let wide = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let narrow = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let f = serde_json::json!({ + "id":frame,"type":"frame","name":"Grid","width":430,"height":100,"shapes":[wide,narrow], + "layout":"grid","layoutJustifyContent":"stretch","layoutAlignContent":"start","layoutJustifyItems":"center", + "layoutGap":{"columnGap":10,"rowGap":0},"layoutPadding":{"p1":0,"p2":10,"p3":0,"p4":10}, + "layoutGridColumns":[{"type":"auto"},{"type":"auto"},{"type":"fixed","value":100}], + "layoutGridRows":[{"type":"auto"}], + "layoutGridCells":{ + "11111111-1111-1111-1111-111111111111":{"shapes":[wide],"row":1,"column":1}, + "22222222-2222-2222-2222-222222222222":{"shapes":[narrow],"row":1,"column":2} + } + }); + let w = serde_json::json!({"id":wide,"parentId":frame,"type":"rect","name":"Wide","width":150,"height":40}); + let n = serde_json::json!({"id":narrow,"parentId":frame,"type":"rect","name":"Narrow","width":50,"height":40}); + let (doc, warnings) = import(&archive_with_shapes(&[(frame, f), (wide, w), (narrow, n)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let layout = doc.nodes().find(|n| n.name == "Grid").unwrap().layout.clone().unwrap(); + assert_eq!(layout.column_tracks, [GridTrack::fill(195.0), GridTrack::fill(95.0), GridTrack::fixed(100.0)]); + assert_eq!(layout.row_tracks, [GridTrack::hug()]); + assert_eq!(layout.main_align, MainAlign::Center); + } + + #[test] + fn grid_tracks_and_one_based_cell_positions_are_mapped() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let child = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let cell = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let f = serde_json::json!({ + "id":frame,"type":"frame","name":"Grid board", "width":320,"height":180, + "shapes":[child], "layout":"grid", "layoutGap":{"rowGap":9,"columnGap":8}, + "layoutGridColumns":[{"type":"fixed","value":100},{"type":"flex","value":1}], + "layoutGridRows":[{"type":"auto"}], + "layoutGridCells":{cell:{"shapes":[child],"row":1,"column":2,"rowSpan":1,"columnSpan":1,"alignSelf":"center"}} + }); + let c = serde_json::json!({"id":child,"parentId":frame,"type":"rect","name":"Cell","width":20,"height":20}); + let (doc, warnings) = import(&archive_with_shapes(&[(frame, f), (child, c)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let n = doc.nodes().find(|n| n.name == "Grid board").unwrap(); + let layout = n.layout.as_ref().unwrap(); + assert_eq!(layout.mode, LayoutMode::Grid); + assert_eq!((layout.grid_columns, layout.grid_rows), (2, 1)); + assert_eq!(layout.column_tracks[0], GridTrack::fixed(100.0)); + assert_eq!(layout.column_tracks[1], GridTrack::fill(1.0)); + assert_eq!(layout.row_tracks[0], GridTrack::hug()); + let c = doc.nodes().find(|n| n.name == "Cell").unwrap(); + assert_eq!((c.child_layout.grid_row, c.child_layout.grid_column), (Some(0), Some(1))); + assert_eq!(c.child_layout.grid_align_v, Some(CellAlign::Center)); + assert!(doc.validate().is_empty()); + } + + #[test] + fn masters_and_simple_copies_become_native_linked_instances() { + let master = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let copy = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let component = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let shapes = [ + ( + master, + serde_json::json!({"id":master,"name":"Primary button","type":"frame","width":100,"height":40,"componentId":component,"mainInstance":true}), + ), + ( + copy, + serde_json::json!({"id":copy,"name":"Button copy","type":"frame","x":120,"width":100,"height":40,"componentId":component,"shapeRef":master}), + ), + ]; + let components = [(component, serde_json::json!({"id":component,"name":"Button","mainInstanceId":master}))]; + let (doc, warnings) = import(&archive_with_components(&shapes, &components)).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let original = doc.nodes().find(|n| n.name == "Primary button").unwrap(); + assert!(matches!(original.kind, NodeKind::Component { .. })); + assert_eq!(original.plugin_data.get("penpot.componentMasterId").map(String::as_str), Some(component)); + let copy = doc.nodes().find(|n| n.name == "Button copy").unwrap(); + assert!(matches!(copy.kind, NodeKind::Instance { component, .. } if component == original.id)); + assert_eq!(copy.source, Some(original.id)); + assert_eq!(copy.plugin_data.get("penpot.componentLinkStatus").map(String::as_str), Some("linked")); + assert_eq!(copy.plugin_data.get("penpot.shapeRef").map(String::as_str), Some(master)); + assert!(doc.validate().is_empty()); + } + + #[test] + fn variant_container_becomes_a_component_set_with_property_names() { + let set = "10000000-0000-0000-0000-000000000001"; + let small = "10000000-0000-0000-0000-000000000002"; + let large = "10000000-0000-0000-0000-000000000003"; + let (small_c, large_c) = ("20000000-0000-0000-0000-000000000002", "20000000-0000-0000-0000-000000000003"); + let main = |id: &str, component: &str, x: i32| { + serde_json::json!({"id":id,"name":"Star","type":"frame","parentId":set,"x":x,"width":40,"height":40, + "componentId":component,"mainInstance":true,"componentRoot":true,"variantId":set}) + }; + let shapes = [ + ( + set, + serde_json::json!({"id":set,"name":"Basic / Star","type":"frame","isVariantContainer":true,"width":200,"height":60,"shapes":[small,large]}), + ), + (small, main(small, small_c, 0)), + (large, main(large, large_c, 60)), + ]; + let props = |size: &str| serde_json::json!([{"name":"size","value":size},{"name":"color","value":"white, bright"}]); + let components = [ + (small_c, serde_json::json!({"id":small_c,"name":"Star","mainInstanceId":small,"variantId":set,"variantProperties":props("small")})), + (large_c, serde_json::json!({"id":large_c,"name":"Star","mainInstanceId":large,"variantId":set,"variantProperties":props("large")})), + ]; + let (doc, warnings) = import(&archive_with_components(&shapes, &components)).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("variant layers")), "{warnings:?}"); + let set = doc.nodes().find(|n| n.name == "Basic / Star").unwrap(); + assert!(matches!(set.kind, NodeKind::ComponentSet { .. })); + let names: BTreeSet<&str> = set.children.iter().filter_map(|c| doc.node(*c)).map(|n| n.name.as_str()).collect(); + assert_eq!(names, BTreeSet::from(["size=small, color=white bright", "size=large, color=white bright"])); + assert!( + set.children.iter().filter_map(|c| doc.node(*c)).all(|n| n.plugin_data.get("penpot.originalName").map(String::as_str) == Some("Star")) + ); + assert!(doc.validate().is_empty()); + } + + #[test] + fn copies_of_mains_with_nested_instances_link_with_native_nested_instances() { + let (icon, icon_c) = ("30000000-0000-0000-0000-000000000001", "40000000-0000-0000-0000-000000000001"); + let (button, button_c) = ("30000000-0000-0000-0000-000000000002", "40000000-0000-0000-0000-000000000002"); + let nested_in_main = "30000000-0000-0000-0000-000000000003"; + let copy = "30000000-0000-0000-0000-000000000004"; + let nested_in_copy = "30000000-0000-0000-0000-000000000005"; + let shapes = [ + ( + icon, + serde_json::json!({"id":icon,"name":"Icon","type":"frame","width":16,"height":16,"componentId":icon_c,"mainInstance":true,"componentRoot":true}), + ), + ( + button, + serde_json::json!({"id":button,"name":"Button","type":"frame","x":100,"width":80,"height":30,"shapes":[nested_in_main], + "componentId":button_c,"mainInstance":true,"componentRoot":true}), + ), + ( + nested_in_main, + serde_json::json!({"id":nested_in_main,"name":"Icon","type":"frame","parentId":button,"x":105,"y":5,"width":16,"height":16, + "componentId":icon_c,"shapeRef":icon}), + ), + ( + copy, + serde_json::json!({"id":copy,"name":"Button","type":"frame","x":300,"width":80,"height":30,"shapes":[nested_in_copy], + "componentId":button_c,"shapeRef":button,"componentRoot":true}), + ), + ( + nested_in_copy, + serde_json::json!({"id":nested_in_copy,"name":"Icon","type":"frame","parentId":copy,"x":305,"y":5,"width":16,"height":16, + "componentId":icon_c,"shapeRef":nested_in_main}), + ), + ]; + let components = [ + (icon_c, serde_json::json!({"id":icon_c,"name":"Icon","mainInstanceId":icon})), + (button_c, serde_json::json!({"id":button_c,"name":"Button","mainInstanceId":button})), + ]; + let (doc, warnings) = import(&archive_with_components(&shapes, &components)).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("kept independent") || w.contains("no native link")), "{warnings:?}"); + let by_penpot = |id: &str| doc.nodes().find(|n| n.plugin_data.get("penpot.id").map(String::as_str) == Some(id)).unwrap(); + let (icon, button, nested_main, copy, nested_copy) = + (by_penpot(icon), by_penpot(button), by_penpot(nested_in_main), by_penpot(copy), by_penpot(nested_in_copy)); + assert!(matches!(nested_main.kind, NodeKind::Instance { component, .. } if component == icon.id)); + assert!(matches!(copy.kind, NodeKind::Instance { component, .. } if component == button.id)); + assert!(matches!(nested_copy.kind, NodeKind::Instance { component, .. } if component == icon.id)); + assert_eq!(nested_copy.source, Some(nested_main.id)); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_transform_matrix_wins_and_children_are_not_transformed_twice() { + let frame = "50000000-0000-0000-0000-000000000001"; + let child = "50000000-0000-0000-0000-000000000002"; + let mirror = serde_json::json!({"a":-1,"b":0,"c":0,"d":1,"e":0,"f":0}); + let shapes = [ + ( + frame, + serde_json::json!({"id":frame,"name":"Mirrored","type":"frame","x":100,"y":50,"width":200,"height":100, + "rotation":180,"transform":mirror,"shapes":[child]}), + ), + ( + child, + serde_json::json!({"id":child,"name":"Dot","type":"rect","parentId":frame,"x":110,"y":60,"width":10,"height":10, + "rotation":180,"transform":mirror}), + ), + ]; + let (doc, _) = import(&archive_with_shapes(&shapes)).unwrap(); + let frame = doc.nodes().find(|n| n.name == "Mirrored").unwrap(); + let child = doc.nodes().find(|n| n.name == "Dot").unwrap(); + let world = |t: Affine, x: f64, y: f64| t * figcraft_doc::geom::Point::new(x, y); + assert_eq!(world(frame.transform, 0.0, 0.0), figcraft_doc::geom::Point::new(300.0, 50.0)); + let child_world = frame.transform * child.transform; + assert_eq!(world(child_world, 0.0, 0.0), figcraft_doc::geom::Point::new(120.0, 60.0)); + assert_eq!(world(child_world, 10.0, 10.0), figcraft_doc::geom::Point::new(110.0, 70.0)); + } + + #[test] + fn penpot_themes_become_modes_with_aliases_math_and_active_theme() { + let tokens = serde_json::json!({ + "Core": { + "gray": {"100": {"$value":"#F0F0F0","$type":"color"}, "900": {"$value":"rgb(17, 17, 17)","$type":"color"}}, + "space": {"base": {"$value":"8px","$type":"spacing"}, "lg": {"$value":"round({space.base} * 1.5 + max(2, 4))","$type":"spacing"}}, + "broken": {"$value":"{space.missing} * 2","$type":"spacing"}, + "uses-broken": {"$value":"{broken}","$type":"spacing"} + }, + "Mode/Light": {"bg": {"$value":"{gray.100}","$type":"color"}}, + "Mode/Dark": {"bg": {"$value":"{gray.900}","$type":"color"}}, + "$themes": [ + {"id":"t-base","name":"Base","group":"Always","selectedTokenSets":{"Core":"enabled"}}, + {"id":"t-light","name":"Light","group":"Mode","selectedTokenSets":{"Mode/Light":"enabled"}}, + {"id":"t-dark","name":"Dark","group":"Mode","selectedTokenSets":{"Mode/Dark":"enabled"}} + ], + "$metadata": {"tokenSetOrder":["Core","Mode/Light","Mode/Dark"], "activeThemes":["Always/Base","Mode/Dark"], "activeSets":["Core","Mode/Dark"]} + }); + let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("Unrecognized")), "{warnings:?}"); + let vars = &doc.variables; + let mode = vars.collections.iter().find(|c| c.name == "Mode").unwrap(); + assert_eq!(mode.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Light", "Dark"]); + let id = |name: &str| vars.variables.iter().find(|v| v.name.ends_with(&format!("/{name}"))).map(|v| v.id.clone()); + let bg = id("bg").unwrap(); + let in_mode = |m: usize| BTreeMap::from([(mode.id.clone(), mode.modes[m].id.clone())]); + assert_eq!(vars.resolve(&bg, &in_mode(0)), Some(VariableValue::Color(Color::from_hex("#F0F0F0").unwrap()))); + assert_eq!(vars.resolve(&bg, &in_mode(1)), Some(VariableValue::Color(Color::from_rgb8_const(17, 17, 17)))); + assert_eq!(vars.resolve(&id("space.lg").unwrap(), &BTreeMap::new()), Some(VariableValue::Number(16.0))); + assert!(id("broken").is_none() && id("uses-broken").is_none()); + let page = doc.node(doc.pages[0]).unwrap(); + assert_eq!(page.var_modes.get(&mode.id), Some(&mode.modes[1].id)); + } + + #[test] + fn linked_instance_preserves_child_text_and_color_overrides() { + let master = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let copy = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let component = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let main_text = "11111111-1111-1111-1111-111111111111"; + let copy_text = "22222222-2222-2222-2222-222222222222"; + let shapes = [ + ( + master, + serde_json::json!({"id":master,"name":"Master", "type":"frame", "shapes":[main_text],"width":100,"height":40,"componentId":component,"mainInstance":true}), + ), + ( + main_text, + serde_json::json!({"id":main_text,"name":"Label","type":"text","parentId":master,"width":90,"height":20,"content":{"children":[{"text":"Default"}]},"fills":[{"fillColor":"#000000"}]}), + ), + ( + copy, + serde_json::json!({"id":copy,"name":"Button copy","type":"frame","x":180,"shapes":[copy_text],"width":100,"height":40,"componentId":component,"shapeRef":master}), + ), + ( + copy_text, + serde_json::json!({"id":copy_text,"name":"Label","type":"text","parentId":copy,"shapeRef":main_text,"width":90,"height":20,"content":{"children":[{"text":"Changed"}]},"fills":[{"fillColor":"#FF0000"}]}), + ), + ]; + let components = [(component, serde_json::json!({"id":component,"mainInstanceId":master}))]; + let (doc, warnings) = import(&archive_with_components(&shapes, &components)).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let m = doc.nodes().find(|n| n.name == "Master").unwrap(); + let i = doc.nodes().find(|n| n.name == "Button copy").unwrap(); + assert!(matches!(i.kind, NodeKind::Instance { component, .. } if component == m.id)); + assert_eq!(i.source, Some(m.id)); + assert_ne!(i.transform, m.transform); + let label = doc.nodes().find(|n| n.plugin_data.get("penpot.id").is_some_and(|v| v == copy_text)).unwrap(); + let orig = doc.nodes().find(|n| n.plugin_data.get("penpot.id").is_some_and(|v| v == main_text)).unwrap(); + assert_eq!(label.source, Some(orig.id)); + assert!(label.overrides.contains("characters")); + assert!(label.overrides.contains("fills")); + assert!(matches!(&label.kind, NodeKind::Text(t) if t.characters == "Changed")); + assert!(doc.validate().is_empty()); + } + + #[test] + fn detached_or_misordered_copies_are_not_linked() { + let master = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let copy = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let component = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let main_child = "11111111-1111-1111-1111-111111111111"; + let extra = "22222222-2222-2222-2222-222222222222"; + let shapes = [ + (master, serde_json::json!({"id":master,"name":"Master","type":"frame","shapes":[main_child],"componentId":component})), + (main_child, serde_json::json!({"id":main_child,"name":"Original","type":"rect","parentId":master})), + (copy, serde_json::json!({"id":copy,"name":"Detached copy","type":"frame","shapes":[extra],"componentId":component,"shapeRef":master})), + (extra, serde_json::json!({"id":extra,"name":"Inserted layer","type":"rect","parentId":copy})), + ]; + let components = [(component, serde_json::json!({"id":component,"mainInstanceId":master}))]; + let (doc, warnings) = import(&archive_with_components(&shapes, &components)).unwrap(); + let copy_node = doc.nodes().find(|n| n.name == "Detached copy").unwrap(); + assert!(matches!(copy_node.kind, NodeKind::Frame)); + assert_eq!(copy_node.source, None); + assert_eq!(copy_node.children.len(), 1); + assert!(warnings.iter().any(|w| w.contains("kept independent"))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn unsupported_layouts_keep_positions_and_source_through_native_and_penpot_saves() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let child = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + for (parent_props, child_props, warning) in [ + (serde_json::json!({"layoutFlexDir":"row-reverse"}), serde_json::json!({}), "reverse flow"), + (serde_json::json!({"layoutFlexDir":"column-reverse"}), serde_json::json!({}), "reverse flow"), + (serde_json::json!({"layoutAlignItems":"stretch"}), serde_json::json!({}), "stretch alignment"), + (serde_json::json!({"layoutWrapType":"wrap","layoutAlignContent":"space-around"}), serde_json::json!({}), "wrapped-line distribution"), + (serde_json::json!({"layoutWrapType":"wrap","layoutAlignContent":"space-evenly"}), serde_json::json!({}), "wrapped-line distribution"), + (serde_json::json!({"layoutPadding":{"p1":-8,"p2":4,"p3":2,"p4":1}}), serde_json::json!({}), "negative padding"), + ( + serde_json::json!({"layout":"grid","layoutGridColumns":[{"type":"percent","value":25}],"layoutGridRows":[{"type":"auto"}]}), + serde_json::json!({}), + "percentage tracks", + ), + (serde_json::json!({}), serde_json::json!({"layoutItemMargin":{"m1":12,"m2":0,"m3":-3,"m4":2}}), "item margins"), + (serde_json::json!({}), serde_json::json!({"layoutItemAlignSelf":"end"}), "self-alignment"), + ] { + let mut parent = serde_json::json!({"id":frame,"type":"frame","name":"Layout","layout":"flex","width":200,"height":100,"shapes":[child]}); + parent.as_object_mut().unwrap().extend(parent_props.as_object().unwrap().clone()); + let mut item = serde_json::json!({"id":child,"type":"rect","name":"Child","parentId":frame,"x":17,"y":23,"width":40,"height":20}); + item.as_object_mut().unwrap().extend(child_props.as_object().unwrap().clone()); + let (doc, warnings) = import(&archive_with_shapes(&[(frame, parent), (child, item)])).unwrap(); + assert!(warnings.iter().any(|w| w.contains(warning)), "{warning}: {warnings:?}"); + let native = crate::save_bytes(&doc, None).unwrap(); + let saved = crate::load_bytes(&native).unwrap().0; + let output = export(&saved).unwrap().0; + let (back, _) = import(&output).unwrap(); + for doc in [&doc, &saved, &back] { + let parent = doc.nodes().find(|n| n.name == "Layout").unwrap(); + let child = doc.nodes().find(|n| n.name == "Child").unwrap(); + assert!(parent.layout.is_none(), "{warning}"); + assert_eq!((child.transform.translation().x, child.transform.translation().y, child.width, child.height), (17.0, 23.0, 40.0, 20.0)); + for (node, props) in [(parent, &parent_props), (child, &child_props)] { + for (key, value) in props.as_object().unwrap() { + assert_eq!(node.plugin_data.get(&format!("penpot.source.{key}")), Some(&value.to_string()), "{warning}: {key}"); + } + } + } + } + } + + #[test] + fn an_unknown_flex_direction_is_kept_at_absolute_positions() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let f = serde_json::json!({"id":frame,"type":"frame","name":"Diagonal","layout":"flex","layoutFlexDir":"diagonal"}); + let (doc, warnings) = import(&archive_with_shapes(&[(frame, f)])).unwrap(); + let n = doc.nodes().find(|n| n.name == "Diagonal").unwrap(); + assert!(n.layout.is_none()); + assert!(warnings.iter().any(|w| w.contains("Unknown Penpot flex direction"))); + } + + #[test] + fn penpot_back_to_front_shapes_keep_visual_z_order() { + let back = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let front = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let shapes = [ + (back, serde_json::json!({"id":back,"type":"rect","name":"Back"})), + (front, serde_json::json!({"id":front,"type":"rect","name":"Front"})), + ]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let page = doc.pages[0]; + let names = doc.node(page).unwrap().children.iter().map(|id| doc.node(*id).unwrap().name.as_str()).collect::>(); + assert_eq!(names, ["Back", "Front"]); + } + + #[test] + fn flex_children_flow_from_the_top_penpot_layer() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let second = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let first = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let shapes = [ + (frame, serde_json::json!({"id":frame,"type":"frame","name":"Row","layout":"flex","layoutFlexDir":"row","shapes":[second,first]})), + (second, serde_json::json!({"id":second,"parentId":frame,"type":"rect","name":"Second"})), + (first, serde_json::json!({"id":first,"parentId":frame,"type":"rect","name":"First"})), + ]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let row = doc.nodes().find(|n| n.name == "Row").unwrap(); + let names = row.children.iter().map(|id| doc.node(*id).unwrap().name.as_str()).collect::>(); + assert_eq!(names, ["First", "Second"]); + assert!(row.layout.as_ref().unwrap().reverse_z); + } + + #[test] + fn multiline_paragraphs_remain_separated() { + let frame = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let f = serde_json::json!({"id":frame,"type":"text","name":"Multi","content":{"type":"root","children":[{"type":"paragraph-set","children":[ + {"type":"paragraph","children":[{"text":"Alpha"}]}, + {"type":"paragraph","children":[{"text":"Beta"}]} + ]}]}}); + let (doc, warnings) = import(&archive_with_shapes(&[(frame, f)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let n = doc.nodes().find(|n| n.name == "Multi").unwrap(); + match &n.kind { + NodeKind::Text(t) => assert_eq!(t.characters, "Alpha\nBeta"), + _ => panic!("expected text"), + } + } + + #[test] + fn unfilled_shapes_inherit_fills_from_groups_like_svg() { + let ids = [ + "c4000000-0000-0000-0000-000000000001", + "c4000000-0000-0000-0000-000000000002", + "c4000000-0000-0000-0000-000000000003", + "c4000000-0000-0000-0000-000000000004", + "c4000000-0000-0000-0000-000000000005", + "c4000000-0000-0000-0000-000000000006", + "c4000000-0000-0000-0000-000000000007", + ]; + let path = |id: &'static str, name: &str, parent: &str, attrs: Value| { + ( + id, + serde_json::json!({"id":id,"type":"path","name":name,"parentId":parent,"svgAttrs":attrs,"fills":[], + "content":"M0 0 L10 0 L10 10 Z"}), + ) + }; + let shapes = [ + (ids[0], serde_json::json!({"id":ids[0],"type":"group","name":"Icon","svgAttrs":{"width":"48"},"fills":[],"shapes":[ids[1], ids[2]]})), + path(ids[1], "Outline", ids[0], serde_json::json!({})), + path(ids[2], "Cutout", ids[0], serde_json::json!({"fill":"none"})), + (ids[3], serde_json::json!({"id":ids[3],"type":"group","name":"Tinted","fills":[{"fillColor":"#A74FFF"}],"shapes":[ids[4]]})), + path(ids[4], "Glyph", ids[3], serde_json::json!({})), + (ids[5], serde_json::json!({"id":ids[5],"type":"frame","name":"Board","fills":[],"shapes":[ids[6]],"x":0,"y":0,"width":10,"height":10})), + path(ids[6], "Loose", ids[5], serde_json::json!({})), + ]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("paint-server"))); + let fill = |name: &str| { + doc.nodes().find(|n| n.name == name).unwrap().fills.iter().filter_map(Paint::solid_color).map(|c| c.to_rgba8()).collect::>() + }; + assert_eq!(fill("Outline"), [[0, 0, 0, 255]]); + assert!(fill("Cutout").is_empty()); + assert_eq!(fill("Glyph"), [[0xA7, 0x4F, 0xFF, 255]]); + assert!(fill("Loose").is_empty()); + } + + #[test] + fn svg_style_fills_win_and_colorless_fills_do_not_inherit() { + let group = "c6000000-0000-0000-0000-000000000001"; + let ids = ["c6000000-0000-0000-0000-000000000002", "c6000000-0000-0000-0000-000000000003", "c6000000-0000-0000-0000-000000000004"]; + let rect = |id: &'static str, name: &str, fills: Value, attrs: Value| { + ( + id, + serde_json::json!({"id":id,"type":"rect","name":name,"parentId":group,"x":0,"y":0,"width":10,"height":10,"fills":fills,"svgAttrs":attrs}), + ) + }; + let shapes = [ + (group, serde_json::json!({"id":group,"type":"group","name":"Svg","svgAttrs":{"id":"layer1"},"fills":[],"shapes":ids})), + rect(ids[0], "Gradient", serde_json::json!([{"fillOpacity":1}]), serde_json::json!({"style":{"fill":"url(#linearGradient927)"}})), + rect(ids[1], "Styled", serde_json::json!([{"fillColor":"#FF0000"}]), serde_json::json!({"style":{"fill":"#00FF00"}})), + rect(ids[2], "Colorless", serde_json::json!([{"fillOpacity":1}]), serde_json::json!({})), + ]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + let fill = |name: &str| { + doc.nodes().find(|n| n.name == name).unwrap().fills.iter().filter_map(Paint::solid_color).map(|c| c.to_rgba8()).collect::>() + }; + assert!(fill("Gradient").is_empty()); + assert_eq!(fill("Styled"), [[0, 255, 0, 255]]); + assert!(fill("Colorless").is_empty()); + assert!(warnings.iter().any(|w| w.contains("paint-server"))); + } + + #[test] + fn svg_even_odd_fill_rule_keeps_holes_open() { + let id = "ca000000-0000-0000-0000-000000000001"; + let ring = serde_json::json!({"id":id,"type":"path","name":"Ring","fills":[{"fillColor":"#000000"}], + "svgAttrs":{"fillRule":"evenodd"},"content":"M0 0 L100 0 L100 100 L0 100 Z M25 25 L75 25 L75 75 L25 75 Z"}); + let (doc, _) = import(&archive_with_shapes(&[(id, ring)])).unwrap(); + let NodeKind::Vector { network } = &doc.nodes().find(|n| n.name == "Ring").unwrap().kind else { panic!("expected vector") }; + assert!(!network.regions.is_empty()); + assert!(network.regions.iter().all(|r| r.winding == figcraft_doc::WindingRule::EvenOdd)); + } + + #[test] + fn svg_raw_boxes_are_never_painted() { + let id = "c9000000-0000-0000-0000-000000000001"; + let raw = serde_json::json!({"id":id,"type":"svg-raw","name":"svg-text","x":0,"y":0,"width":1390,"height":1040, + "fills":[{"fillColor":"#617670"}],"strokes":[{"strokeColor":"#000000","strokeWidth":1}], + "content":{"tag":"text","attrs":{"x":"10","y":"20"},"content":["Title"]}}); + let (doc, _) = import(&archive_with_shapes(&[(id, raw)])).unwrap(); + let n = doc.nodes().find(|n| n.name == "svg-text").unwrap(); + assert!(n.fills.is_empty() && n.strokes.is_empty()); + } + + #[test] + fn empty_penpot_paths_draw_nothing() { + let id = "c7000000-0000-0000-0000-000000000001"; + let path = serde_json::json!({"id":id,"type":"path","name":"Stale","content":[],"fills":[{"fillColor":"#8445bc"}], + "selrect":{"x":10,"y":10,"width":300,"height":100}}); + let (doc, warnings) = import(&archive_with_shapes(&[(id, path)])).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("could not be decoded")), "{warnings:?}"); + let n = doc.nodes().find(|n| n.name == "Stale").unwrap(); + assert!(matches!(&n.kind, NodeKind::Vector { network } if network.vertices.is_empty())); + } + + #[test] + fn open_filled_subpaths_fill_like_svg_but_stroke_open() { + let id = "c8000000-0000-0000-0000-000000000001"; + let path = serde_json::json!({"id":id,"type":"path","name":"Script","fills":[{"fillColor":"#262626"}], + "strokes":[{"strokeColor":"#000000","strokeWidth":1}], + "content":"M0 0 L100 0 L100 100 M200 0 L300 0 L300 100 L200 100 Z"}); + let (doc, _) = import(&archive_with_shapes(&[(id, path)])).unwrap(); + let NodeKind::Vector { network } = &doc.nodes().find(|n| n.name == "Script").unwrap().kind else { panic!("expected vector") }; + assert_eq!(network.segments.len(), 6); + assert_eq!(network.regions.iter().map(|r| r.loops.len()).sum::(), 2); + assert!(network.fill_path().contains(figcraft_doc::geom::Point::new(90.0, 10.0))); + let unfilled = "c8000000-0000-0000-0000-000000000002"; + let line = serde_json::json!({"id":unfilled,"type":"path","name":"Line","strokes":[{"strokeColor":"#000000","strokeWidth":1}], + "content":"M0 0 L100 0 L100 100"}); + let (doc, _) = import(&archive_with_shapes(&[(unfilled, line)])).unwrap(); + let NodeKind::Vector { network } = &doc.nodes().find(|n| n.name == "Line").unwrap().kind else { panic!("expected vector") }; + assert!(network.regions.is_empty()); + } + + #[test] + fn first_penpot_paint_is_topmost() { + let id = "c3000000-0000-0000-0000-000000000001"; + let rect = serde_json::json!({"id":id,"type":"rect","name":"Stack","x":0,"y":0,"width":10,"height":10, + "fills":[{"fillColor":"#FF0000"},{"fillColor":"#00FF00"},{"fillColor":"#0000FF"}], + "strokes":[{"strokeColor":"#111111"},{"strokeColor":"#222222"}]}); + let (doc, _) = import(&archive_with_shapes(&[(id, rect)])).unwrap(); + let n = doc.nodes().find(|n| n.name == "Stack").unwrap(); + let hex = |p: &[Paint]| p.iter().filter_map(Paint::solid_color).map(|c| c.to_rgba8()).collect::>(); + assert_eq!(hex(&n.fills), [[0, 0, 255, 255], [0, 255, 0, 255], [255, 0, 0, 255]]); + assert_eq!(hex(&n.strokes), [[0x22, 0x22, 0x22, 255], [0x11, 0x11, 0x11, 255]]); + } + + #[test] + fn paragraph_breaks_take_the_ended_paragraph_style() { + let id = "cb000000-0000-0000-0000-000000000001"; + let para = |text: &str| serde_json::json!({"type":"paragraph","fontSize":"20","lineHeight":"1.5","children":[{"text":text}]}); + let text = serde_json::json!({"id":id,"type":"text","name":"Paragraphs","x":0,"y":0,"width":300,"height":100, + "content":{"type":"root","children":[{"type":"paragraph-set","children":[para("One"), para(""), para("Two")]}]}}); + let (doc, _) = import(&archive_with_shapes(&[(id, text)])).unwrap(); + let NodeKind::Text(t) = &doc.nodes().find(|n| n.name == "Paragraphs").unwrap().kind else { panic!("expected text") }; + assert_eq!(t.characters, "One\n\nTwo"); + for i in 3..5 { + assert_eq!(t.style_at(i).font_size, 20.0, "{i}"); + assert_eq!(t.style_at(i).line_height, LineHeight::Percent(150.0), "{i}"); + } + } + + #[test] + fn legacy_span_fill_colors_apply_when_fills_are_absent() { + let id = "c5000000-0000-0000-0000-000000000001"; + let text = serde_json::json!({"id":id,"type":"text","name":"Legacy","x":0,"y":0,"width":200,"height":40,"fills":[], + "content":{"type":"root","children":[{"type":"paragraph-set","children":[{"type":"paragraph","fillColor":"#ffffff","children":[ + {"text":"White ","fontSize":"20","fillColor":"#ffffff","fillOpacity":1}, + {"text":"Navy","fontSize":"20","fillColor":"#ffffff","fills":[{"fillColor":"#151035"}]} + ]}]}]}}); + let (doc, _) = import(&archive_with_shapes(&[(id, text)])).unwrap(); + let NodeKind::Text(t) = &doc.nodes().find(|n| n.name == "Legacy").unwrap().kind else { panic!("expected text") }; + let color = |i: usize| t.style_at(i).fills.as_deref().and_then(|f| f.first()).and_then(Paint::solid_color).map(|c| c.to_rgba8()); + assert_eq!(color(0), Some([255, 255, 255, 255])); + assert_eq!(color(7), Some([0x15, 0x10, 0x35, 255])); + } + + #[test] + fn named_font_weights_and_gradient_text_fills_survive() { + let id = "c2000000-0000-0000-0000-000000000001"; + let gradient = serde_json::json!({"type":"linear","startX":0.5,"startY":0,"endX":0.5,"endY":1,"width":1, + "stops":[{"color":"#25cbd9","offset":0,"opacity":1},{"color":"#00d1b8","offset":1,"opacity":1}]}); + let text = serde_json::json!({"id":id,"type":"text","name":"Title","x":0,"y":0,"width":200,"height":40, + "content":{"type":"root","children":[{"type":"paragraph-set","children":[{"type":"paragraph","children":[ + {"text":"Bold ","fontFamily":"sourcesanspro","fontSize":"20","fontWeight":"bold","fills":[{"fillColor":"#151035"}]}, + {"text":"Teal","fontFamily":"sourcesanspro","fontSize":"20","fontWeight":"300","fills":[{"fillColorGradient":gradient}]} + ]}]}]}}); + let (doc, _) = import(&archive_with_shapes(&[(id, text)])).unwrap(); + let NodeKind::Text(t) = &doc.nodes().find(|n| n.name == "Title").unwrap().kind else { panic!("expected text") }; + assert_eq!(t.style_at(0).font_style, "Bold"); + let teal = t.style_at(6); + assert_eq!(teal.font_style, "Light"); + assert!(matches!(teal.fills.as_deref(), Some([p]) if matches!(p.kind, PaintKind::Gradient(_)))); + } + + #[test] + fn near_identity_transforms_keep_boards_on_whole_pixels() { + let id = "c1000000-0000-0000-0000-000000000001"; + let board = serde_json::json!({"id":id,"type":"frame","name":"Phone","x":1088,"y":1582,"width":384,"height":854,"shapes":[], + "transform":{"a":0.9999999994569041,"b":1.745329248779787e-08,"c":1.745329237710893e-08,"d":1.000000000271156,"e":0,"f":0}}); + let (doc, _) = import(&archive_with_shapes(&[(id, board)])).unwrap(); + let n = doc.nodes().find(|n| n.name == "Phone").unwrap(); + assert_eq!(n.transform.as_coeffs(), [1.0, 0.0, 0.0, 1.0, 1088.0, 1582.0]); + let id = "c1000000-0000-0000-0000-000000000002"; + let tilted = serde_json::json!({"id":id,"type":"rect","name":"Tilted","x":0,"y":0,"width":10,"height":10,"rotation":0.06, + "transform":{"a":0.99999945,"b":0.00104719736,"c":-0.00104719736,"d":0.99999945,"e":0,"f":0}}); + let (doc, _) = import(&archive_with_shapes(&[(id, tilted)])).unwrap(); + assert_ne!(doc.nodes().find(|n| n.name == "Tilted").unwrap().transform.as_coeffs()[1], 0.0); + } + + #[test] + fn boards_clip_unless_show_content_is_set() { + let shape = |id: &'static str, kind: &str, name: &str, show: Option| { + let mut v = serde_json::json!({"id":id,"type":kind,"name":name,"x":0,"y":0,"width":10,"height":10,"shapes":[]}); + if let Some(show) = show { + v["showContent"] = show.into(); + } + (id, v) + }; + let shapes = [ + shape("c0000000-0000-0000-0000-000000000001", "frame", "Default", None), + shape("c0000000-0000-0000-0000-000000000002", "frame", "Shown", Some(true)), + shape("c0000000-0000-0000-0000-000000000003", "frame", "Hidden", Some(false)), + shape("c0000000-0000-0000-0000-000000000004", "group", "Group", None), + ]; + let (doc, _) = import(&archive_with_shapes(&shapes)).unwrap(); + let clip = |name: &str| doc.nodes().find(|n| n.name == name).unwrap().clip; + assert_eq!([clip("Default"), clip("Shown"), clip("Hidden"), clip("Group")], [true, false, true, false]); + } + + #[test] + fn stroke_styles_use_penpot_dash_arrays() { + let rect = |id: &'static str, style: &str| { + ( + id, + serde_json::json!({"id":id,"type":"rect","name":style,"x":0,"y":0,"width":10,"height":10, + "strokes":[{"strokeColor":"#000000","strokeWidth":2,"strokeStyle":style}]}), + ) + }; + let shapes = [ + rect("d0000000-0000-0000-0000-000000000001", "dotted"), + rect("d0000000-0000-0000-0000-000000000002", "dashed"), + rect("d0000000-0000-0000-0000-000000000003", "mixed"), + rect("d0000000-0000-0000-0000-000000000004", "solid"), + ]; + let (doc, _) = import(&archive_with_shapes(&shapes)).unwrap(); + let stroke = |name: &str| doc.nodes().find(|n| n.name == name).unwrap().stroke.clone(); + assert_eq!(stroke("dotted").dashes, [0.0, 7.0]); + assert_eq!((stroke("dotted").cap_start, stroke("dotted").cap_end), (StrokeCap::Round, StrokeCap::Round)); + assert_eq!(stroke("dashed").dashes, [12.0, 12.0]); + assert_eq!(stroke("mixed").dashes, [7.0, 7.0, 3.0, 7.0]); + assert!(stroke("solid").dashes.is_empty()); + let id = "d0000000-0000-0000-0000-000000000005"; + let arrow = serde_json::json!({"id":id,"type":"rect","name":"Arrow","x":0,"y":0,"width":10,"height":10, + "strokes":[{"strokeColor":"#000000","strokeWidth":2,"strokeStyle":"dotted","strokeCapStart":"square","strokeCapEnd":"line-arrow"}]}); + let (doc, _) = import(&archive_with_shapes(&[(id, arrow)])).unwrap(); + let s = doc.nodes().find(|n| n.name == "Arrow").unwrap().stroke.clone(); + assert_eq!((s.cap_start, s.cap_end), (StrokeCap::Round, StrokeCap::LineArrow)); + } + + #[test] + fn gradient_strokes_shadows_and_constraints_are_editable() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let gradient = serde_json::json!({ + "id":id,"type":"rect","name":"Gradient test","x":10,"y":20,"width":200,"height":100, + "fills":[{"fillColor":"#00FF00","fillOpacity":0.7,"fillColorGradient":{ + "type":"linear","startX":0,"startY":0,"endX":1,"endY":1, + "stops":[{"offset":0,"color":"#FF0000","opacity":1},{"offset":1,"color":"#0000FF","opacity":0.5}] + }}], + "strokes":[{"strokeColor":"#ABCD12","strokeOpacity":0.5,"strokeWidth":3,"strokeAlignment":"outer","strokeStyle":"dashed"}], + "shadows":[{"style":"drop-shadow","color":{"color":"#123456","opacity":0.5},"offsetX":4,"offsetY":6,"blur":7,"spread":2}], + "constraintsH":"right","constraintsV":"bottom","flipX":true + }); + let (doc, warnings) = import(&archive_with_shapes(&[(id, gradient)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let n = doc.nodes().find(|n| n.name == "Gradient test").unwrap(); + assert!(matches!(&n.fills[0].kind, figcraft_doc::color::PaintKind::Gradient(_))); + assert_eq!(n.strokes.len(), 1); + assert!((n.strokes[0].opacity - 0.5).abs() < 0.01); + assert_eq!(n.stroke.weight, 3.0); + assert_eq!(n.stroke.align, StrokeAlign::Outside); + assert!(!n.stroke.dashes.is_empty()); + assert_eq!(n.constraints.horizontal, HConstraint::Right); + assert_eq!(n.constraints.vertical, VConstraint::Bottom); + assert!(matches!(&n.effects[0].kind, EffectKind::DropShadow(_))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn blur_modes_import_as_native_editable_effects() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let rectangle = serde_json::json!({"id":id,"name":"Blurred","type":"rect", + "blur":{"type":"background-blur","value":8,"hidden":false}}); + let (doc, warnings) = import(&archive_with_shapes(&[(id, rectangle)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let n = doc.nodes().find(|n| n.name == "Blurred").unwrap(); + assert!(matches!(&n.effects[0].kind, EffectKind::BackgroundBlur(b) if b.radius == 8.0)); + } + + #[test] + fn bezier_path_is_editable_vector_and_unsupported_paths_warn() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let vector = serde_json::json!({ + "id":id,"type":"path","name":"Curve","x":10,"y":20,"width":100,"height":100, + "content":[ + {"command":"move-to","params":{"x":10,"y":20}}, + {"command":"line-to","params":{"x":110,"y":20}}, + {"command":"curve-to","params":{"c1x":110,"c1y":60,"c2x":80,"c2y":120,"x":10,"y":120}}, + {"command":"close-path"} + ] + }); + let (doc, warnings) = import(&archive_with_shapes(&[(id, vector.clone())])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let n = doc.nodes().find(|n| n.name == "Curve").unwrap(); + match &n.kind { + NodeKind::Vector { network } => { + assert_eq!(network.regions.len(), 1); + assert!(network.vertices.iter().any(|p| p.x == 100.0)); + } + other => panic!("Expected editable vector, got {other:?}"), + } + let mut wrong = vector; + // Elliptical arcs are supported in v5; use a genuinely unknown command + // to exercise the fallback instead of relying on an obsolete expectation. + wrong["content"][1]["command"] = "not-a-penpot-path-command".into(); + let (doc, warnings) = import(&archive_with_shapes(&[(id, wrong)])).unwrap(); + assert!(warnings.iter().any(|w| w.contains("Unsupported Penpot path command"))); + assert!(matches!(doc.nodes().find(|n| n.name == "Curve").unwrap().kind, NodeKind::Frame)); + } + + #[test] + fn svg_string_path_content_from_penpot_2_10_is_editable_vector() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let vector = serde_json::json!({ + "id":id,"type":"path","name":"Icon","x":10,"y":20,"width":100,"height":100, + "content":"M10.0,20.0L110.0,20.0C110.0,60.0,80.0,120.0,10.0,120.0Z" + }); + let (doc, warnings) = import(&archive_with_shapes(&[(id, vector)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + match &doc.nodes().find(|n| n.name == "Icon").unwrap().kind { + NodeKind::Vector { network } => { + assert_eq!(network.regions.len(), 1); + assert!(network.vertices.iter().any(|p| p.x == 100.0 && p.y == 0.0)); + } + other => panic!("Expected editable vector, got {other:?}"), + } + } + + #[test] + fn path_with_null_geometry_takes_its_box_from_selrect() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let line = serde_json::json!({ + "id":id,"type":"path","name":"Divider","x":null,"y":null,"width":null,"height":null, + "selrect":{"x":380,"y":30,"width":0.01,"height":919}, + "content":"M380.0,30.0L380.0,949.0" + }); + let (doc, warnings) = import(&archive_with_shapes(&[(id, line)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let n = doc.nodes().find(|n| n.name == "Divider").unwrap(); + assert_eq!((n.transform.translation().x, n.transform.translation().y, n.height), (380.0, 30.0, 919.0)); + match &n.kind { + NodeKind::Vector { network } => assert!(network.vertices.iter().all(|p| p.x == 0.0)), + other => panic!("Expected editable vector, got {other:?}"), + } + } + + #[test] + fn styled_text_uses_unicode_character_offsets() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let value = serde_json::json!({ + "id":id,"type":"text","name":"Styled","x":0,"y":0,"width":100,"height":30, + "content":{"type":"root","children":[ + {"text":"á😀","fontFamily":"Inter","fontSize":"20","fills":[{"fillColor":"#000000"}]}, + {"text":"B","fontFamily":"Inter","fontSize":"30","fontWeight":"700","fills":[{"fillColor":"#FF0000"}]} + ]} + }); + let (doc, warnings) = import(&archive_with_shapes(&[(id, value)])).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let n = doc.nodes().find(|n| n.name == "Styled").unwrap(); + if let NodeKind::Text(t) = &n.kind { + assert_eq!(t.characters, "á😀B"); + assert_eq!(t.runs.len(), 2); + assert_eq!((t.runs[0].start, t.runs[0].end), (0, 2)); + assert_eq!(t.runs[0].style.font_size, 20.0); + assert_eq!((t.runs[1].start, t.runs[1].end), (2, 3)); + assert_eq!(t.runs[1].style.font_size, 30.0); + } else { + panic!("expected text"); + } + } + + fn test_archive() -> Vec { + let file = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let page = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + let shape = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let buffer = Cursor::new(Vec::new()); + let mut z = zip::ZipWriter::new(buffer); + let entries = [ + ("manifest.json".to_string(), format!(r#"{{"type":"penpot/export-files","version":1,"files":[{{"id":"{file}","name":"Test"}}]}}"#)), + (format!("files/{file}/pages/{page}.json"), format!(r#"{{"id":"{page}","name":"Canvas","index":0}}"#)), + (format!("files/{file}/pages/{page}/{ROOT_ID}.json"), format!(r#"{{"id":"{ROOT_ID}","type":"frame","shapes":["{shape}"]}}"#)), + ( + format!("files/{file}/pages/{page}/{shape}.json"), + format!( + r##"{{"id":"{shape}","name":"Rect","type":"rect","x":10,"y":20,"width":100,"height":50,"fills":[{{"fillColor":"#AA3366"}}]}}"## + ), + ), + ]; + for (path, data) in entries { + z.start_file(path, SimpleFileOptions::default()).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() + } + + #[test] + fn imports_editable_rectangle_and_page() { + let (doc, notes) = import(&test_archive()).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + assert_eq!(doc.name, "Test"); + assert_eq!(doc.pages.len(), 1); + let rect = doc.nodes().find(|n| n.name == "Rect").unwrap(); + assert!(matches!(rect.kind, NodeKind::Rectangle)); + assert_eq!((rect.width, rect.height), (100.0, 50.0)); + assert_eq!(rect.fills.len(), 1); + } + + /// Optional real-file regression test. Kept out of the repository: it may contain user assets. + /// Run with FIGCRAFT_PENPOT_SAMPLE=/path/to/example.penpot cargo test -p figcraft-format penpot + #[test] + fn imports_sample_when_provided() { + let Ok(path) = std::env::var("FIGCRAFT_PENPOT_SAMPLE") else { + return; + }; + let bytes = std::fs::read(path).unwrap(); + let (doc, warnings) = import(&bytes).unwrap(); + assert!(!doc.pages.is_empty(), "{warnings:?}"); + assert!(doc.validate().is_empty(), "{warnings:?}"); + } + + #[test] + fn external_component_reference_is_preserved_and_reported() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let unknown = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let frame = serde_json::json!({ + "id": id, "name": "External library button", "type": "frame", + "componentId": unknown, "componentFile": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", + "width": 200, "height": 50 + }); + let (doc, warnings) = import(&archive_with_shapes(&[(id, frame)])).unwrap(); + let node = doc.nodes().find(|n| n.name == "External library button").unwrap(); + assert!(matches!(node.kind, NodeKind::Frame)); + assert_eq!(node.plugin_data.get("penpot.componentLinkStatus").map(String::as_str), Some("not-linked")); + assert!(warnings.iter().any(|w| w.contains("could not find their local masters"))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn rejects_unrelated_zip_and_corrupt_input() { + assert!(import(b"not a zip").is_err()); + let mut zip = test_archive(); + zip.truncate(20); + assert!(import(&zip).is_err()); + } + fn archive_with_design_system(token_data: Value, typography: Option, color: Option, layer: Option) -> Vec { + let file = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let page = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let typography_id = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let color_id = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let root_children = if layer.is_some() { vec![id] } else { Vec::new() }; + let mut entries = vec![ + ( + "manifest.json".to_string(), + serde_json::json!({ + "type":"penpot/export-files", "version":1, "files":[{"id":file,"name":"Design system"}] + }), + ), + (format!("files/{file}/pages/{page}.json"), serde_json::json!({"id":page,"name":"Page","index":0})), + ( + format!("files/{file}/pages/{page}/{ROOT_ID}.json"), + serde_json::json!({ + "id":ROOT_ID,"type":"frame","shapes":root_children + }), + ), + (format!("files/{file}/tokens.json"), token_data), + ]; + if let Some(value) = typography { + entries.push((format!("files/{file}/typographies/{typography_id}.json"), value)); + } + if let Some(value) = color { + entries.push((format!("files/{file}/colors/{color_id}.json"), value)); + } + if let Some(value) = layer { + entries.push((format!("files/{file}/pages/{page}/{id}.json"), value)); + } + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (path, value) in entries { + zip.start_file(path, SimpleFileOptions::default()).unwrap(); + zip.write_all(value.to_string().as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() + } + + #[test] + fn v20_svg_paint_server_converts_simple_linear_gradient_without_flattening() { + let id = "a0000000-0000-0000-0000-000000000001"; + let shape = serde_json::json!({ + "id":id,"type":"rect","name":"SVG gradient","width":100,"height":60, + "svgAttrs":{"style":{"fill":"url(#brand-fill)"}}, + "svgDefs":[{"tag":"defs","content":[{"tag":"linearGradient","attrs":{"id":"brand-fill","x1":"0%","y1":"0%","x2":"100%","y2":"0%"}, + "content":[{"tag":"stop","attrs":{"offset":"0%","stop-color":"#112233"}}, + {"tag":"stop","attrs":{"offset":"100%","stop-color":"#AABBCC","stop-opacity":"0.5"}}]}]}] + }); + let (doc, warnings) = import(&archive_with_shapes(&[(id, shape)])).unwrap(); + let node = doc.nodes().find(|n| n.name == "SVG gradient").unwrap(); + let PaintKind::Gradient(g) = &node.fills[0].kind else { panic!("not a native gradient") }; + assert_eq!(g.kind, GradientKind::Linear); + assert_eq!(g.stops.len(), 2); + assert!((g.stops[1].color.a - 0.5).abs() < 0.01); + assert!(!warnings.iter().any(|w| w.contains("paint-server")), "{warnings:?}"); + assert!(doc.validate().is_empty()); + } + + fn gradient_of<'a>(doc: &'a Document, name: &str) -> &'a Gradient { + let node = doc.nodes().find(|n| n.name == name).unwrap(); + let Some(PaintKind::Gradient(g)) = node.fills.first().map(|p| &p.kind) else { panic!("{name}: no native gradient") }; + g + } + + fn near(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3 + } + + #[test] + fn user_space_gradient_coordinates_follow_only_the_svg_transform_like_penpot() { + let id = "a1000000-0000-0000-0000-000000000001"; + let path = serde_json::json!({"id":id,"type":"path","name":"Logo","content":"M2563 2341 L2585 2341 L2585 2385 L2563 2385 Z", + "selrect":{"x":2563,"y":2341,"width":22,"height":44},"svgViewbox":{"x":0,"y":2,"width":11,"height":22}, + "svgTransform":{"a":1,"b":0,"c":0,"d":1,"e":2563,"f":2339},"svgAttrs":{"fill":"url(#paint2)"}, + "svgDefs":{"paint2":{"tag":"linearGradient","attrs":{"id":"paint2","x1":"8.25","y1":"2","x2":"26.95","y2":"39.4","gradientUnits":"userSpaceOnUse"}, + "content":[{"tag":"stop","attrs":{"stopColor":"#1CA7FD"}},{"tag":"stop","attrs":{"offset":"1","stopColor":"#148CFC"}}]}}}); + let (doc, warnings) = import(&archive_with_shapes(&[(id, path)])).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("paint-server")), "{warnings:?}"); + let g = gradient_of(&doc, "Logo"); + assert!(near(g.from, (0.375, 0.0)) && near(g.to, (1.225, 0.85)), "{:?} {:?}", g.from, g.to); + assert_eq!(g.stops[0].position, 0.0); + } + + #[test] + fn user_space_gradient_inherits_stops_through_href_with_css_stop_styles() { + let id = "a1000000-0000-0000-0000-000000000002"; + let rect = serde_json::json!({"id":id,"type":"rect","name":"Cover","x":1,"y":0,"width":1389,"height":724, + "selrect":{"x":1,"y":0,"width":1389,"height":724},"svgViewbox":{"x":0.28694993,"y":0.087415434,"width":367.53668,"height":191.9277}, + "svgTransform":null,"fills":[{"fillOpacity":1}],"svgAttrs":{"style":{"fill":"url(#linearGradient927)"}}, + "svgDefs":{ + "linearGradient927":{"tag":"linearGradient","attrs":{"gradientUnits":"userSpaceOnUse","id":"linearGradient927","xlinkHref":"#linearGradient925", + "x1":"0.28694993","y1":"96.05127","x2":"367.82364","y2":"96.05127"},"content":[]}, + "linearGradient925":{"tag":"linearGradient","attrs":{"id":"linearGradient925"},"content":[ + {"tag":"stop","attrs":{"style":"stop-color:#f6901e;stop-opacity:1;","offset":"0"}}, + {"tag":"stop","attrs":{"style":"stop-color:#972296;stop-opacity:0.5;","offset":"1"}}]}}}); + let (doc, _) = import(&archive_with_shapes(&[(id, rect)])).unwrap(); + let g = gradient_of(&doc, "Cover"); + assert!(near(g.from, (-0.00051, 0.13267)) && near(g.to, (0.26409, 0.13267)), "{:?} {:?}", g.from, g.to); + assert_eq!(g.stops[0].color.to_rgba8(), [0xf6, 0x90, 0x1e, 255]); + assert!((g.stops[1].color.a - 0.5).abs() < 0.01); + } + + #[test] + fn user_space_radial_gradient_maps_centre_and_radii() { + let id = "a1000000-0000-0000-0000-000000000003"; + let rect = serde_json::json!({"id":id,"type":"rect","name":"Glow","x":0,"y":0,"width":100,"height":50, + "svgAttrs":{"fill":"url(#glow)"}, + "svgDefs":{"glow":{"tag":"radialGradient","attrs":{"id":"glow","cx":"50","cy":"25","r":"25","gradientUnits":"userSpaceOnUse"}, + "content":[{"tag":"stop","attrs":{"stop-color":"#FFFFFF"}},{"tag":"stop","attrs":{"offset":"1","stop-color":"#000000"}}]}}}); + let (doc, _) = import(&archive_with_shapes(&[(id, rect)])).unwrap(); + let g = gradient_of(&doc, "Glow"); + assert_eq!(g.kind, GradientKind::Radial); + assert!(near(g.from, (0.5, 0.5)) && near(g.to, (0.75, 0.5)) && near(g.width, (0.5, 1.0)), "{:?} {:?} {:?}", g.from, g.to, g.width); + } + + #[test] + fn same_gradient_id_in_two_shapes_keeps_each_shapes_own_colours() { + let shape = |id: &'static str, name: &str, color: &str| { + ( + id, + serde_json::json!({"id":id,"type":"rect","name":name,"x":0,"y":0,"width":10,"height":10,"svgAttrs":{"fill":"url(#paint0)"}, + "svgDefs":{"paint0":{"tag":"linearGradient","attrs":{"id":"paint0","x1":"0","y1":"0","x2":"10","y2":"0","gradientUnits":"userSpaceOnUse"}, + "content":[{"tag":"stop","attrs":{"stopColor":color}},{"tag":"stop","attrs":{"offset":"1","stopColor":color}}]}}}), + ) + }; + let shapes = + [shape("a1000000-0000-0000-0000-000000000004", "Red", "#FF0000"), shape("a1000000-0000-0000-0000-000000000005", "Blue", "#0000FF")]; + let (doc, _) = import(&archive_with_shapes(&shapes)).unwrap(); + assert_eq!(gradient_of(&doc, "Red").stops[0].color.to_rgba8(), [255, 0, 0, 255]); + assert_eq!(gradient_of(&doc, "Blue").stops[0].color.to_rgba8(), [0, 0, 255, 255]); + } + + #[test] + fn v20_unsupported_svg_gradient_does_not_approximate_or_erase_source() { + let id = "a0000000-0000-0000-0000-000000000002"; + let shape = serde_json::json!({"id":id,"type":"rect","name":"Transformed", + "svgAttrs":{"style":{"fill":"url(#tx)"}}, + "svgDefs":[{"tag":"radialGradient","attrs":{"id":"tx","fx":"0.2"}, + "content":[{"tag":"stop","attrs":{"stop-color":"#FF0000"}}]}]}); + let (doc, warnings) = import(&archive_with_shapes(&[(id, shape)])).unwrap(); + let node = doc.nodes().find(|n| n.name == "Transformed").unwrap(); + assert!(node.fills.is_empty()); + assert!(warnings.iter().any(|w| w.contains("paint-server")), "{warnings:?}"); + assert!(node.plugin_data.contains_key("penpot.source.svgAttrs")); + } + + #[test] + fn v20_compound_tokens_are_reusable_and_survive_round_trip() { + let tokens = serde_json::json!({"sets":{"Core":{"tokens":{ + "title":{"type":"typography","value":{"fontFamily":"Spicy Rice","fontSize":"24px","fontWeight":400,"lineHeight":"32px","letterSpacing":"1px"}}, + "elevation":{"type":"boxShadow","value":[{"color":"#112233","x":"2px","y":"4px","blur":"6px","spread":"1px"}]}, + "unsafe":{"type":"boxShadow","value":{"color":"#112233","x":"{spacing.sm}","y":"0px","blur":"1px"}} + }}}}); + let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap(); + assert_eq!(doc.styles.len(), 2); + assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/title" + && matches!(&s.kind, StyleKind::Text { style } + if style.font_family == "Spicy Rice" && style.font_size == 24.0 && style.line_height == LineHeight::Pixels(32.0)))); + assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/elevation" + && matches!(&s.kind, StyleKind::Effect { effects } + if effects.len() == 1 && matches!(&effects[0].kind, EffectKind::DropShadow(sh) if sh.offset == (2.0,4.0) && sh.radius == 6.0)))); + assert!(warnings.iter().any(|w| w.contains("compound tokens could not")), "{warnings:?}"); + let saved = crate::save_bytes(&doc, None).unwrap(); + let (opened, issues) = crate::load_bytes(&saved).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + assert_eq!(opened.styles, doc.styles); + } + + #[test] + fn penpot_v11_imports_scalar_tokens_into_native_variables_and_preserves_theme_source() { + let tokens = serde_json::json!({ + "sets":{"Core":{"tokens":{ + "color":{"primary":{"type":"color","value":"#0066CC"}}, + "spacing":{"sm":{"type":"dimension","value":"8px"}}, + "copy":{"cta":{"type":"string","value":"Buy"}}, + "flags":{"enabled":{"type":"boolean","value":true}}, + "aliases":{"secondary":{"type":"color","value":"{color.primary}"}} + }}}, + "themes":{"Light":{"sets":["Core"]}},"activeThemes":["Light"] + }); + let (doc, warnings) = import(&archive_with_design_system(tokens.clone(), None, None, None)).unwrap(); + assert_eq!(doc.variables.collections.len(), 1); + assert_eq!(doc.variables.variables.len(), 5); + let values = &doc.variables; + assert!(matches!(values.resolve("Core/color.primary", &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert_eq!(values.resolve("Core/spacing.sm", &BTreeMap::new()), Some(VariableValue::Number(8.0))); + assert!(matches!(values.resolve("Core/copy.cta", &BTreeMap::new()), Some(VariableValue::String(ref s)) if s == "Buy")); + assert_eq!(values.resolve("Core/flags.enabled", &BTreeMap::new()), Some(VariableValue::Boolean(true))); + assert!(warnings.iter().any(|s| s.contains("themes"))); + assert!(matches!(values.resolve("Core/aliases.secondary", &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert!(!warnings.iter().any(|s| s.contains("unsupported or unresolved"))); + let page = doc.node(doc.first_page().unwrap()).unwrap(); + assert_eq!( + page.plugin_data.get("penpot.tokens.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").and_then(|s| serde_json::from_str::(s).ok()), + Some(tokens) + ); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_v11_imports_named_text_and_color_styles() { + let typography_id = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let color_id = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let typ = serde_json::json!({"id":typography_id,"name":"Heading","path":"Brand", + "fontFamily":"Inter","fontSize":"24","fontWeight":"700","fontStyle":"normal"}); + let color = serde_json::json!({"id":color_id,"name":"Primary","path":"Brand", + "color":"#224466","opacity":0.5}); + let (doc, warnings) = import(&archive_with_design_system(serde_json::json!({"sets":{}}), Some(typ), Some(color), None)).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(doc.styles.len(), 2); + assert!( + doc.styles.iter().any(|s| s.name == "Brand/Heading" + && matches!(&s.kind, StyleKind::Text { style } if style.font_family == "Inter" && style.font_size == 24.0)) + ); + assert!(doc.styles.iter().any(|s| s.name == "Brand/Primary" && matches!(s.kind, StyleKind::Paint { .. }))); + assert_eq!(doc.document_colors.len(), 1); + } + + #[test] + fn penpot_v11_variant_fields_are_preserved_without_false_native_link() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let shape = serde_json::json!({ + "id":id,"name":"Button / Disabled","type":"frame","width":100,"height":32, + "variantId":"ffffffff-ffff-ffff-ffff-ffffffffffff", + "variantProperties":{"State":"Disabled"}, + }); + let (doc, warnings) = import(&archive_with_design_system(serde_json::json!({"sets":{}}), None, None, Some(shape))).unwrap(); + let button = doc.nodes().find(|node| node.name == "Button / Disabled").unwrap(); + assert!(matches!(button.kind, NodeKind::Frame)); + assert_eq!(button.plugin_data.get("penpot.variantId").map(String::as_str), Some("ffffffff-ffff-ffff-ffff-ffffffffffff")); + assert!(button.plugin_data.contains_key("penpot.source.variantProperties")); + assert!(warnings.iter().any(|s| s.contains("variant layers"))); + } + + #[test] + fn penpot_v14_binds_matching_color_and_size_tokens_and_round_trips() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let tokens = serde_json::json!({"sets":{"Core":{"tokens":{ + "palette":{"primary":{"type":"color","value":"#336699"}, + "button":{"type":"color","value":"{palette.primary}"}}, + "size":{"width":{"type":"dimension","value":"120px"}, + "height":{"type":"number","value":60}} + }}}}); + let layer = serde_json::json!({"id":id,"name":"Token button","type":"rect", + "x":5,"y":8,"width":120,"height":60, + "fills":[{"fillColor":"#336699"}], + "strokes":[{"strokeColor":"#336699","strokeWidth":1}], + "appliedTokens":{ + "fill":"palette.button","strokeColor":"palette.primary", + "width":"size.width","height":"size.height" + } + }); + let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, Some(layer))).unwrap(); + let n = doc.nodes().find(|n| n.name == "Token button").unwrap(); + assert_eq!(n.bound.len(), 4); + let color = doc.variables.get("Core/palette.button").unwrap(); + assert_eq!(n.bound.get("fills/0/color"), Some(&color.id)); + assert_eq!(n.bound.get("width"), doc.variables.get("Core/size.width").map(|v| &v.id)); + assert_eq!(n.bound.get("height"), doc.variables.get("Core/size.height").map(|v| &v.id)); + assert!(warnings.iter().any(|w| w.contains("Linked 4 Penpot token")), "{warnings:?}"); + let bytes = crate::save_bytes(&doc, None).unwrap(); + let (restored, issues) = crate::load_bytes(&bytes).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + let restored_node = restored.nodes().find(|n| n.name == "Token button").unwrap(); + assert_eq!(restored_node.bound, n.bound); + assert!(restored_node.plugin_data.contains_key("penpot.source.appliedTokens")); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_v14_refuses_ambiguous_sets_and_different_resolved_values() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let tokens = serde_json::json!({"sets":{ + "Core":{"tokens":{"brand":{"type":"color","value":"#336699"}, + "unique":{"type":"color","value":"#000000"}}}, + "Alternate":{"tokens":{"brand":{"type":"color","value":"#336699"}}} + }}); + let layer = serde_json::json!({"id":id,"name":"Do not relink","type":"rect", + "x":1,"y":2,"width":80,"height":40, + "fills":[{"fillColor":"#336699"}], + "appliedTokens":{"fill":"brand","strokeColor":"unique", "opacity":"brand"} + }); + let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, Some(layer))).unwrap(); + let n = doc.nodes().find(|n| n.name == "Do not relink").unwrap(); + assert!(n.bound.is_empty()); + assert!(n.plugin_data.contains_key("penpot.source.appliedTokens")); + assert!(warnings.iter().any(|w| w.contains("could not be safely linked")), "{warnings:?}"); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_v14_bindings_reject_color_mismatch_and_accept_explicit_set_name() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let tokens = serde_json::json!({"sets":{ + "Core":{"tokens":{"brand":{"type":"color","value":"#336699"}, + "w":{"type":"number","value":125}}}, + "Alternate":{"tokens":{"brand":{"type":"color","value":"#000000"}}} + }}); + let layer = serde_json::json!({"id":id,"name":"Explicit set","type":"rect", + "x":1,"y":2,"width":50,"height":40, + "fills":[{"fillColor":"#336699"}], + "appliedTokens":{"fill":"Core/brand", "width":"w"} + }); + let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, Some(layer))).unwrap(); + let n = doc.nodes().find(|n| n.name == "Explicit set").unwrap(); + assert_eq!(n.bound.len(), 1); + assert_eq!(n.bound.get("fills/0/color"), doc.variables.get("Core/brand").map(|v| &v.id)); + assert!(!n.bound.contains_key("width")); + assert!(warnings.iter().any(|w| w.contains("could not be safely linked")), "{warnings:?}"); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_v13_alias_chains_are_editable_and_survive_round_trip() { + let tokens = serde_json::json!({"sets":{"Core":{"tokens":{ + "palette":{ + "base":{"type":"color","value":"#336699"}, + "middle":{"type":"color","value":"{palette.base}"}, + "accent":{"type":"color","value":"{palette.middle}"} + }, + "space":{ + "small":{"type":"dimension","value":"12px"}, + "copy":{"type":"dimension","value":"{space.small}"} + }, + "label":{"type":"string","value":"Button"}, + "labelAlias":{"type":"string","value":"{label}"} + }}}}); + let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap(); + assert_eq!(doc.variables.variables.len(), 7); + let v = &doc.variables; + assert!(matches!(v.get("Core/palette.middle").unwrap().values.values().next(), Some(VariableValue::Alias(_)))); + assert!(matches!(v.resolve("Core/palette.accent", &BTreeMap::new()), Some(VariableValue::Color(_)))); + assert_eq!(v.resolve("Core/space.copy", &BTreeMap::new()), Some(VariableValue::Number(12.0))); + assert_eq!(v.resolve("Core/labelAlias", &BTreeMap::new()), Some(VariableValue::String("Button".into()))); + assert!(!warnings.iter().any(|w| w.contains("unsupported or unresolved")), "{warnings:?}"); + let saved = crate::save_bytes(&doc, None).unwrap(); + let (reloaded, issues) = crate::load_bytes(&saved).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + assert_eq!(reloaded.variables, doc.variables); + } + + #[test] + fn penpot_v13_rejects_bad_aliases_without_creating_live_broken_references() { + let tokens = serde_json::json!({"sets":{"Core":{"tokens":{ + "base":{"type":"color","value":"#123456"}, + "unknown":{"type":"color","value":"{not.present}"}, + "cycleA":{"type":"color","value":"{cycleB}"}, + "cycleB":{"type":"color","value":"{cycleA}"}, + "number":{"type":"number","value":5}, + "wrongType":{"type":"color","value":"{number}"} + }}}}); + let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap(); + assert_eq!(doc.variables.variables.len(), 2); + assert!(doc.variables.get("Core/unknown").is_none()); + assert!(doc.variables.get("Core/cycleA").is_none()); + assert!(doc.variables.get("Core/wrongType").is_none()); + assert!(warnings.iter().any(|w| w.contains("unsupported or unresolved"))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_v13_recovers_orphaned_subtree_as_editable_layers() { + let missing = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let parent = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let child = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let items = [ + ( + parent, + serde_json::json!({"id":parent,"name":"Detached group","type":"group", + "parentId":missing,"x":100,"y":75,"width":120,"height":90,"shapes":[child]}), + ), + ( + child, + serde_json::json!({"id":child,"name":"Recovered child","type":"rect", + "parentId":parent,"x":110,"y":85,"width":40,"height":20}), + ), + ]; + let (doc, warnings) = import(&archive_with_shapes(&items)).unwrap(); + let parent_node = doc.nodes().find(|n| n.name == "Detached group").unwrap(); + let child_node = doc.nodes().find(|n| n.name == "Recovered child").unwrap(); + assert_eq!(parent_node.parent, doc.first_page()); + assert_eq!(child_node.parent, Some(parent_node.id)); + assert!(matches!(child_node.kind, NodeKind::Rectangle)); + assert!(warnings.iter().any(|w| w.contains("orphan layer"))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_v13_recovers_cyclic_parent_graph_without_recursing_forever() { + let a = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let b = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let items = [ + ( + a, + serde_json::json!({"id":a,"name":"A","type":"group","parentId":b, + "width":80,"height":80,"shapes":[b]}), + ), + ( + b, + serde_json::json!({"id":b,"name":"B","type":"rect","parentId":a, + "width":20,"height":20,"shapes":[a]}), + ), + ]; + let (doc, warnings) = import(&archive_with_shapes(&items)).unwrap(); + assert_eq!(doc.nodes().filter(|n| n.name == "A" || n.name == "B").count(), 2); + assert!(warnings.iter().any(|w| w.contains("cyclic layer graph"))); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_v12_round_trip_preserves_styles_variables_and_original_theme_json() { + let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let typ_id = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let tokens = serde_json::json!({ + "sets":{"Core":{"tokens":{"brand":{"type":"color","value":"#224466"}}}}, + "themes":{"Light":{"sets":["Core"]}}, "activeThemes":["Light"] + }); + let original = archive_with_design_system( + tokens.clone(), + Some(serde_json::json!({"id":typ_id,"name":"Body","fontFamily":"Inter","fontSize":16})), + None, + Some(serde_json::json!({"id":id,"type":"frame","name":"Variant / Hover", "width":100, "height":40, + "variantProperties":{"State":"Hover"}})), + ); + let (before, _) = import(&original).unwrap(); + let stored = crate::save_bytes(&before, None).unwrap(); + let (after, issues) = crate::load_bytes(&stored).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + assert_eq!(before.node_count(), after.node_count()); + assert_eq!(before.pages.len(), after.pages.len()); + assert_eq!(before.styles, after.styles); + assert_eq!(before.variables, after.variables); + let page = after.node(after.first_page().unwrap()).unwrap(); + let saved = page.plugin_data.get("penpot.tokens.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); + assert_eq!(serde_json::from_str::(saved).unwrap(), tokens); + let variant = after.nodes().find(|node| node.name == "Variant / Hover").unwrap(); + assert_eq!(variant.plugin_data.get("penpot.source.variantProperties").map(String::as_str), Some(r#"{"State":"Hover"}"#)); + assert!(after.validate().is_empty()); + } + + #[test] + fn penpot_v12_rejects_duplicate_and_traversal_member_paths() { + let original = archive_with_design_system(serde_json::json!({"sets":{}}), None, None, None); + // Deliberate duplicate manifest; accepting this could result in validating + // one manifest and resolving a different one by name. + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, payload) in [("manifest.json", &original[..1]), ("manifest.jsoX", &original[..1])] { + zip.start_file(name, SimpleFileOptions::default()).unwrap(); + zip.write_all(payload).unwrap(); + } + let mut bytes = zip.finish().unwrap().into_inner(); + let twin = b"manifest.jsoX"; + let mut at = 0; + while let Some(i) = bytes[at..].windows(twin.len()).position(|w| w == twin) { + bytes[at + i + twin.len() - 1] = b'n'; + at += i + twin.len(); + } + assert!(import(&bytes).is_err()); + + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file("../manifest.json", SimpleFileOptions::default()).unwrap(); + zip.write_all(b"{}").unwrap(); + assert!(import(&zip.finish().unwrap().into_inner()).is_err()); + } + + #[test] + fn penpot_token_expressions_have_a_bounded_recursion_depth() { + let parse = |text: &str| { + let mut resolve: fn(&str, usize) -> Option = |_, _| None; + TokenExpr { text: text.as_bytes(), at: 0, nesting: 0, resolve: &mut resolve }.parse() + }; + assert_eq!(parse("-(-(-2))"), Some(-2.0)); + assert_eq!(parse("max(1, min(4, 2))"), Some(2.0)); + // All of these used to recurse until a Rust stack overflow aborted the process. + for hostile in + ["-".repeat(100_000) + "1", "(".repeat(100_000) + "1" + &")".repeat(100_000), "min(".repeat(100_000) + "1" + &")".repeat(100_000)] + { + assert_eq!(parse(&hostile), None); + } + let valid = "(".repeat(MAX_TOKEN_EXPR_NESTING - 1) + "1" + &")".repeat(MAX_TOKEN_EXPR_NESTING - 1); + assert_eq!(parse(&valid), Some(1.0)); + let over = "(".repeat(MAX_TOKEN_EXPR_NESTING) + "1" + &")".repeat(MAX_TOKEN_EXPR_NESTING); + assert_eq!(parse(&over), None); + } + + #[test] + fn penpot_token_aliases_share_the_expression_nesting_budget() { + // A nested expression can resolve an alias whose own expression is nested. + // Counting each string separately would allow alias chains to bypass the limit. + let first = "(".repeat(50) + "{second}" + &")".repeat(50); + let second = "(".repeat(50) + "1" + &")".repeat(50); + let values = serde_json::json!({ + "first": {"$value": first}, + "second": {"$value": second}, + "short": {"$value": "1 + {second}"} + }); + let mut set = BTreeMap::new(); + for name in ["first", "second", "short"] { + set.insert(name.to_owned(), &values[name]); + } + let tokens = BTreeMap::from([("Core", set)]); + let mut budget = TokenResolutionBudget { remaining: MAX_TOKEN_RESOLUTION_WORK }; + assert_eq!(token_number("first", &["Core"], &tokens, 0, 0, &mut budget), None); + assert_eq!(token_number("short", &["Core"], &tokens, 0, 0, &mut budget), Some(2.0)); + } + + #[test] + fn penpot_token_branching_aliases_exhaust_shared_work_budget() { + // Repeated references multiply the number of evaluations, even with shallow nesting. + let source: Vec = (0..22) + .map(|i| { + let value = if i == 0 { "0".to_owned() } else { format!("{{t{}}} + {{t{}}}", i - 1, i - 1) }; + serde_json::json!({"$type": "number", "$value": value}) + }) + .collect(); + let set = source.iter().enumerate().map(|(i, v)| (format!("t{i}"), v)).collect(); + let tokens = BTreeMap::from([("Core", set)]); + let mut budget = TokenResolutionBudget { remaining: 1024 }; + assert_eq!(token_number("t21", &["Core"], &tokens, 0, 0, &mut budget), None); + assert!(budget.remaining < 1024); + // The same exhausted budget must also refuse a subsequent, otherwise valid token. + assert_eq!(token_number("t0", &["Core"], &tokens, 0, 0, &mut budget), None); + } + + #[test] + fn penpot_token_work_budget_is_shared_between_independent_tokens() { + let values = serde_json::json!({ + "a": {"$type": "number", "$value": "1 + 2"}, + "b": {"$type": "number", "$value": "3 + 4"}, + "c": {"$type": "number", "$value": "5 + 6"} + }); + let tokens = BTreeMap::from([( + "Core", + BTreeMap::from([("a".to_owned(), &values["a"]), ("b".to_owned(), &values["b"]), ("c".to_owned(), &values["c"])]), + )]); + + // Measure each token independently, then apply exactly their combined cost + // to one shared budget. This also catches changes in accounting rules. + let initial = 100usize; + let mut a_budget = TokenResolutionBudget { remaining: initial }; + assert_eq!(token_number("a", &["Core"], &tokens, 0, 0, &mut a_budget), Some(3.0)); + let a_cost = initial - a_budget.remaining; + let mut b_budget = TokenResolutionBudget { remaining: initial }; + assert_eq!(token_number("b", &["Core"], &tokens, 0, 0, &mut b_budget), Some(7.0)); + let b_cost = initial - b_budget.remaining; + assert!(a_cost > 0 && b_cost > 0); + let mut budget = TokenResolutionBudget { remaining: a_cost + b_cost }; + assert_eq!(token_number("a", &["Core"], &tokens, 0, 0, &mut budget), Some(3.0)); + assert_eq!(token_number("b", &["Core"], &tokens, 0, 0, &mut budget), Some(7.0)); + assert_eq!(token_number("c", &["Core"], &tokens, 0, 0, &mut budget), None); + } + + #[test] + fn penpot_v12_malformed_token_values_are_safe_and_preserve_source() { + let tokens = serde_json::json!({"sets":{"Core":{"tokens":{ + "bad":{"type":"dimension","value":"NaNpx"}, + "huge":{"type":"dimension","value":1e30}, + "unknown":{"type":"shadow","value":{"x":3}}, + "ok":{"type":"number","value":12} + }}}}); + let (doc, warnings) = import(&archive_with_design_system(tokens.clone(), None, None, None)).unwrap(); + assert_eq!(doc.variables.variables.len(), 1); + assert!(warnings.iter().any(|w| w.contains("unsupported or unresolved"))); + let page = doc.node(doc.first_page().unwrap()).unwrap(); + let source = page.plugin_data.get("penpot.tokens.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); + assert_eq!(serde_json::from_str::(source).unwrap(), tokens); + assert!(doc.validate().is_empty()); + } + + #[test] + fn penpot_v12_user_sample_can_be_imported_saved_and_reopened() { + let Ok(path) = std::env::var("FIGCRAFT_PENPOT_SAMPLE") else { + return; + }; + let data = std::fs::read(&path).unwrap(); + let (doc, warnings) = import(&data).unwrap(); + assert!(!doc.pages.is_empty()); + assert!(doc.validate().is_empty(), "{warnings:?}"); + let saved = crate::save_bytes(&doc, None).unwrap(); + let (opened, issues) = crate::load_bytes(&saved).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + assert_eq!(opened.pages.len(), doc.pages.len()); + assert_eq!(opened.node_count(), doc.node_count()); + assert_eq!(opened.variables, doc.variables); + assert_eq!(opened.styles, doc.styles); + for n in doc.nodes().filter(|n| n.plugin_data.contains_key("penpot.id")) { + assert!(opened.nodes().any(|o| o.plugin_data.get("penpot.id") == n.plugin_data.get("penpot.id"))); + } + } + + // --- Prototypes + + use figcraft_doc::proto::Action; + + /// Two frames and a button that navigates from one to the other. + fn prototype_shapes() -> Vec<(&'static str, Value)> { + let first = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let second = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + let button = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + vec![ + (first, serde_json::json!({"id":first,"type":"frame","name":"Home","x":0,"y":0,"width":200,"height":100})), + (second, serde_json::json!({"id":second,"type":"frame","name":"Details","x":300,"y":0,"width":200,"height":100})), + ( + button, + serde_json::json!({"id":button,"type":"rect","name":"Go","parentId":first,"x":10,"y":10,"width":80,"height":30, + "interactions":[{"eventType":"click","actionType":"navigate","destination":second, + "animation":{"animationType":"slide","direction":"right","way":"in","duration":400,"easing":"ease-out"}}]}), + ), + ] + } + + #[test] + fn penpot_navigation_becomes_a_working_figcraft_interaction() { + let (doc, warnings) = import(&archive_with_shapes(&prototype_shapes())).unwrap(); + assert!(doc.validate().is_empty(), "{warnings:?}"); + let button = doc.nodes().find(|n| n.name == "Go").unwrap(); + assert_eq!(button.interactions.len(), 1); + let interaction = &button.interactions[0]; + assert_eq!(interaction.trigger, figcraft_doc::proto::Trigger::OnClick); + assert_eq!(interaction.transition, figcraft_doc::proto::Transition::SlideIn { direction: figcraft_doc::proto::Direction::Right }); + assert_eq!(interaction.duration_ms, 400); + assert_eq!(interaction.easing, figcraft_doc::proto::Easing::EaseOut); + let Action::Navigate { dest, .. } = &interaction.actions[0] else { panic!("expected a navigation") }; + let details = doc.nodes().find(|n| n.name == "Details").unwrap(); + assert_eq!(*dest, details.id, "the destination is the frame the link names"); + } + + #[test] + fn penpot_overlay_links_become_overlay_actions() { + let button = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let overlay = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + let shapes = [ + ( + "cccccccc-cccc-cccc-cccc-cccccccccccc", + serde_json::json!({"id":"cccccccc-cccc-cccc-cccc-cccccccccccc","type":"frame","name":"Home","width":200,"height":100}), + ), + (overlay, serde_json::json!({"id":overlay,"type":"frame","name":"Menu","x":300,"width":120,"height":60})), + ( + button, + serde_json::json!({"id":button,"type":"rect","name":"Menu button","parentId":"cccccccc-cccc-cccc-cccc-cccccccccccc", + "interactions":[{"eventType":"click","actionType":"open-overlay","destination":overlay, + "overlayPosType":"bottom-center","closeClickOutside":true,"backgroundOverlay":true}]}), + ), + ]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + assert!(doc.validate().is_empty(), "{warnings:?}"); + let button = doc.nodes().find(|n| n.name == "Menu button").unwrap(); + let Action::OpenOverlay { dest, position, close_on_outside, background, .. } = &button.interactions[0].actions[0] else { + panic!("expected an overlay action"); + }; + let menu = doc.nodes().find(|n| n.name == "Menu").unwrap(); + assert_eq!(*dest, menu.id); + assert_eq!(*position, figcraft_doc::proto::OverlayPosition::BottomCenter); + assert!(*close_on_outside); + assert!(background.is_some(), "Penpot's overlay background comes across"); + } + + #[test] + fn penpot_toggle_overlay_is_reported_and_preserved_without_becoming_open_overlay() { + let button = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let overlay = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + let shapes = [ + ( + "cccccccc-cccc-cccc-cccc-cccccccccccc", + serde_json::json!({"id":"cccccccc-cccc-cccc-cccc-cccccccccccc","type":"frame","name":"Home","width":200,"height":100}), + ), + (overlay, serde_json::json!({"id":overlay,"type":"frame","name":"Menu","x":300,"width":120,"height":60})), + ( + button, + serde_json::json!({"id":button,"type":"rect","name":"Menu button","parentId":"cccccccc-cccc-cccc-cccc-cccccccccccc", + "interactions":[{"eventType":"click","actionType":"toggle-overlay","destination":overlay,"overlayPosType":"center"}]}), + ), + ]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + assert!(doc.validate().is_empty(), "{warnings:?}"); + let button = doc.nodes().find(|n| n.name == "Menu button").unwrap(); + assert!(button.interactions.is_empty()); + assert!(warnings.iter().any(|w| w.contains("toggle-overlay") && w.contains("skipped"))); + let (back, warnings) = import(&export(&doc).unwrap().0).unwrap(); + assert!(warnings.iter().any(|w| w.contains("toggle-overlay"))); + let back = back.nodes().find(|n| n.name == "Menu button").unwrap(); + assert_eq!(back.plugin_data.get("penpot.source.interactions"), button.plugin_data.get("penpot.source.interactions")); + } + + #[test] + fn penpot_links_to_nothing_are_skipped_with_a_note() { + let button = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + let shapes = [( + button, + serde_json::json!({"id":button,"type":"rect","name":"Dead link","width":10,"height":10, + "interactions":[{"eventType":"click","actionType":"navigate","destination":"99999999-9999-9999-9999-999999999999"}]}), + )]; + let (doc, warnings) = import(&archive_with_shapes(&shapes)).unwrap(); + assert!(doc.validate().is_empty(), "{warnings:?}"); + let button = doc.nodes().find(|n| n.name == "Dead link").unwrap(); + assert!(button.interactions.is_empty(), "a link to nowhere is not a working link"); + assert!(warnings.iter().any(|w| w.contains("named no layer")), "{warnings:?}"); + } + + #[test] + fn penpot_page_flows_become_figcraft_flows() { + let first = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + let shapes = prototype_shapes(); + let page = serde_json::json!({"id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","name":"Canvas","index":0, + "flows":{"11111111-1111-1111-1111-111111111111":{"id":"11111111-1111-1111-1111-111111111111","name":"Checkout","startingFrame":first}}}); + let (doc, warnings) = import(&archive_with_page(&shapes, &[], page)).unwrap(); + assert!(doc.validate().is_empty(), "{warnings:?}"); + let page_id = doc.first_page().unwrap(); + let NodeKind::Page { flows, .. } = &doc.node(page_id).unwrap().kind else { panic!("expected a page") }; + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].name, "Checkout"); + let home = doc.nodes().find(|n| n.name == "Home").unwrap(); + assert_eq!(flows[0].start, home.id); + } + + #[test] + fn penpot_links_survive_a_save_and_reopen() { + let (doc, warnings) = import(&archive_with_shapes(&prototype_shapes())).unwrap(); + assert!(doc.validate().is_empty(), "{warnings:?}"); + let saved = crate::save_bytes(&doc, None).unwrap(); + let (opened, issues) = crate::load_bytes(&saved).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + let button = opened.nodes().find(|n| n.name == "Go").unwrap(); + assert_eq!(button.interactions.len(), 1, "the prototype link survives the round trip"); + } +} diff --git a/crates/format/src/penpot/embedded_fonts.rs b/crates/format/src/penpot/embedded_fonts.rs new file mode 100644 index 000000000..d09896804 --- /dev/null +++ b/crates/format/src/penpot/embedded_fonts.rs @@ -0,0 +1,57 @@ +//! Embedded font detection without decoding or retaining unsupported binaries. + +use super::{BudgetReader, InflateBudget, MAX_ENTRIES, warn}; +use std::io::{Cursor, Read}; +use zip::ZipArchive; + +/// Identify unsupported fonts from signatures without decoding their payloads. +pub(super) fn report(z: &mut ZipArchive>, warnings: &mut Vec, budget: &mut InflateBudget) { + let names: Vec = z + .file_names() + .filter(|n| n.starts_with("objects/") || n.starts_with("fonts/") || n.starts_with("assets/fonts/")) + .take(MAX_ENTRIES) + .map(str::to_owned) + .collect(); + let mut count = 0; + for name in names { + let Ok(member) = z.by_name(&name) else { continue }; + let mut header = [0; 4]; + if (BudgetReader { inner: member, budget }).read_exact(&mut header).is_ok() + && (matches!(&header, b"OTTO" | b"true" | b"ttcf" | b"wOFF" | b"wOF2") || header == [0, 1, 0, 0]) + { + count += 1; + } + } + if count > 0 { + warn( + warnings, + format!( + "Penpot contains {count} embedded font file(s); the current model does not retain font binaries. Install the licensed fonts locally; original family references are preserved" + ), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use zip::{ZipWriter, write::SimpleFileOptions}; + + #[test] + fn uuid_fonts_are_reported_without_decoding_their_payload() { + let mut z = ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("objects/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", SimpleFileOptions::default()).unwrap(); + z.write_all(b"wOF2").unwrap(); + z.write_all(&vec![0; 100_000]).unwrap(); + let bytes = z.finish().unwrap().into_inner(); + let mut z = ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + let mut budget = InflateBudget::new(); + let before = budget.remaining; + let mut warnings = Vec::new(); + report(&mut z, &mut warnings, &mut budget); + assert_eq!(before - budget.remaining, 4); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("does not retain font binaries")); + } +} diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs new file mode 100644 index 000000000..277d7e1f5 --- /dev/null +++ b/crates/format/src/penpot/export.rs @@ -0,0 +1,1805 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Cursor, Write}; + +use figcraft_doc::color::{BlendMode, Color, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind}; +use figcraft_doc::geom::kurbo::PathEl; +use figcraft_doc::geom::{Affine, BezPath, Point}; +use figcraft_doc::proto::{Action, Direction, Easing, Interaction, OverlayPosition, Transition, Trigger}; +use figcraft_doc::{ + BoolOp, CellAlign, ContentAlign, CrossAlign, Document, EffectKind, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, LetterSpacing, + LineHeight, MainAlign, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextDecoration, + TextStyle, VConstraint, VariableType, VariableValue, +}; +use serde_json::{Map, Value, json}; + +use super::{ROOT_ID, fingerprint, layout_fingerprint, strokes_fingerprint, text_fingerprint, tokens_fingerprint, uuid}; +use crate::{FormatError, io_err, json_options}; + +const DEFAULT_FEATURES: &[&str] = &["fdata/path-data", "design-tokens/v1", "variants/v1", "layout/grid", "components/v2", "fdata/shape-data-type"]; + +const MIGRATIONS: &[&str] = &[ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node", + "0017-fix-layout-flex-dir", + "0018-remove-unneeded-objects-from-components", + "0019-fix-missing-swap-slots", + "0020-sync-component-id-with-near-main", + "0021-fix-shape-svg-attrs", + "0022-normalize-component-root-and-resync", + "0023-repair-token-themes-with-inexistent-sets", + "0024b-fix-stroke-cap-placement", + "0025-repair-empty-text-content", + "0026-fix-svg-raw-shapes-uuids", +]; + +const SOURCE_LAYOUT_KEYS: &[&str] = &[ + "layout", + "layoutFlexDir", + "layoutWrapType", + "layoutGap", + "layoutGapType", + "layoutPadding", + "layoutPaddingType", + "layoutJustifyContent", + "layoutJustifyItems", + "layoutAlignItems", + "layoutAlignContent", + "layoutGridDir", + "layoutGridColumns", + "layoutGridRows", + "layoutGridCells", +]; + +const PASSTHROUGH_KEYS: &[&str] = &["pluginData", "svgViewbox", "svgDefs", "layoutItemZIndex", "exports", "hideInViewer"]; + +struct Media { + id: String, + object: String, + mtype: &'static str, + ext: &'static str, + bytes: Vec, + width: u32, + height: u32, +} + +struct Exporter<'a> { + doc: &'a Document, + file_id: String, + page_ids: Vec<(NodeId, String)>, + ids: BTreeMap, + page_of: BTreeMap, + component_ids: BTreeMap, + media: BTreeMap, + token_names: BTreeMap, + unchanged_layouts: BTreeSet, + components: Vec, + warnings: Vec, +} + +/// Write an offline Penpot 2.x archive and warnings for unsupported native features. +pub fn export(doc: &Document) -> Result<(Vec, Vec), FormatError> { + let mut exporter = Exporter::new(doc); + exporter.assign_ids(); + exporter.write() +} + +/// Derive a stable UUID-shaped identifier from a document-local seed. +fn derived_id(seed: &str) -> String { + let hash = crate::image_hash(seed.as_bytes()); + let mut hex: Vec = hash.chars().take(32).collect(); + if let Some(c) = hex.get_mut(12) { + *c = '4'; + } + if let Some(c) = hex.get_mut(16) { + *c = match c.to_digit(16).unwrap_or(0) & 0x3 { + 0 => '8', + 1 => '9', + 2 => 'a', + _ => 'b', + }; + } + let s: String = hex.into_iter().collect(); + let part = |range: std::ops::Range| s.get(range).unwrap_or("0"); + format!("{}-{}-{}-{}-{}", part(0..8), part(8..12), part(12..16), part(16..20), part(20..32)) +} + +/// Encode RGB as hexadecimal; alpha is exported in its separate opacity field. +fn hex(c: Color) -> String { + let [r, g, b, _] = c.to_rgba8(); + format!("#{r:02x}{g:02x}{b:02x}") +} + +/// Emit a finite JSON number rounded to six decimal places. +fn num(v: f64) -> Value { + if !v.is_finite() { + return json!(0); + } + let rounded = (v * 1e6).round() / 1e6; + if rounded.fract() == 0.0 && rounded.abs() < 1e15 { json!(rounded as i64) } else { json!(rounded) } +} + +/// Format a sanitized number for SVG path text. +fn num_text(v: f64) -> String { + match num(v) { + Value::Number(n) => n.to_string(), + _ => "0".into(), + } +} + +/// Decode a preserved Penpot source field from layer metadata. +fn source_value(node: &Node, key: &str) -> Option { + serde_json::from_str(node.plugin_data.get(&format!("penpot.source.{key}"))?).ok() +} + +/// Check whether native state still matches its import-time fingerprint. +fn matches_fingerprint(node: &Node, key: &str, current: Option) -> bool { + current.is_some() && node.plugin_data.get(&format!("penpot.fp.{key}")) == current.as_ref() +} + +/// Recover preserved content for a layer originally imported as raw SVG. +fn svg_raw_source(node: &Node) -> Option { + node.plugin_data.contains_key("penpot.svgRawStatus").then(|| source_value(node, "content")).flatten() +} + +/// Serialize finite path elements as Penpot commands, converting quadratics to cubics. +fn path_content(path: &BezPath) -> Value { + let mut out = Vec::new(); + let mut last = Point::ZERO; + let finite = |p: &Point| p.x.is_finite() && p.y.is_finite(); + for el in path.elements() { + let points_ok = match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => finite(&p), + PathEl::QuadTo(c, p) => finite(&c) && finite(&p), + PathEl::CurveTo(c1, c2, p) => finite(&c1) && finite(&c2) && finite(&p), + PathEl::ClosePath => true, + }; + if !points_ok { + continue; + } + let item = match *el { + PathEl::MoveTo(p) => { + last = p; + json!({"command": "move-to", "params": {"x": num(p.x), "y": num(p.y)}}) + } + PathEl::LineTo(p) => { + last = p; + json!({"command": "line-to", "params": {"x": num(p.x), "y": num(p.y)}}) + } + PathEl::QuadTo(c, p) => { + let c1 = last + (c - last) * (2.0 / 3.0); + let c2 = p + (c - p) * (2.0 / 3.0); + last = p; + json!({"command": "curve-to", "params": {"x": num(p.x), "y": num(p.y), "c1x": num(c1.x), "c1y": num(c1.y), "c2x": num(c2.x), "c2y": num(c2.y)}}) + } + PathEl::CurveTo(c1, c2, p) => { + last = p; + json!({"command": "curve-to", "params": {"x": num(p.x), "y": num(p.y), "c1x": num(c1.x), "c1y": num(c1.y), "c2x": num(c2.x), "c2y": num(c2.y)}}) + } + PathEl::ClosePath => json!({"command": "close-path", "params": {}}), + }; + out.push(item); + } + Value::Array(out) +} + +/// Write the six affine coefficients using Penpot field names. +fn matrix(m: Affine) -> Value { + let [a, b, c, d, e, f] = m.as_coeffs(); + json!({"a": num(a), "b": num(b), "c": num(c), "d": num(d), "e": num(e), "f": num(f)}) +} + +/// Write both rectangle dimensions and corner coordinates. +fn rect_json(x: f64, y: f64, w: f64, h: f64) -> Value { + json!({"x": num(x), "y": num(y), "width": num(w), "height": num(h), "x1": num(x), "y1": num(y), "x2": num(x + w), "y2": num(y + h)}) +} + +/// Insert a token under its nested dotted-name path. +fn insert_token(map: &mut Map, path: &[&str], token: Value) { + match path { + [] => {} + [leaf] => { + map.insert((*leaf).to_owned(), token); + } + [head, rest @ ..] => { + let entry = map.entry((*head).to_owned()).or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + if let Value::Object(next) = entry { + insert_token(next, rest, token); + } + } + } +} + +/// Distinguish a full ellipse from arcs and hollow ellipse shapes. +fn is_full_ellipse(kind: &NodeKind) -> bool { + matches!(kind, NodeKind::Ellipse { start, sweep, inner } if start.abs() < 1e-9 && (sweep - std::f64::consts::TAU).abs() < 1e-9 && inner.abs() < 1e-9) +} + +/// Choose the supported Penpot shape type or omit non-shape nodes. +fn penpot_type(node: &Node) -> Option<&'static str> { + if svg_raw_source(node).is_some() { + return Some("svg-raw"); + } + Some(match &node.kind { + NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { + "frame" + } + NodeKind::Group => "group", + NodeKind::BooleanOp { .. } => "bool", + NodeKind::Rectangle => "rect", + kind @ NodeKind::Ellipse { .. } if is_full_ellipse(kind) => "circle", + NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Line | NodeKind::Vector { .. } => "path", + NodeKind::Text(_) => "text", + NodeKind::Slice | NodeKind::Page { .. } => return None, + }) +} + +/// Map supported blend modes to Penpot names. +fn blend_name(mode: BlendMode) -> Option<&'static str> { + match mode.css() { + "plus-darker" | "plus-lighter" => None, + name => Some(name), + } +} + +/// Serialize gradient stops and unit-box handles, including radial width. +fn gradient_json(g: &Gradient) -> Value { + let (fx, fy) = g.from; + let (tx, ty) = g.to; + let len2 = (tx - fx).powi(2) + (ty - fy).powi(2); + let width = + if g.kind == GradientKind::Linear || len2 <= 1e-12 { 1.0 } else { (((g.width.0 - fx).powi(2) + (g.width.1 - fy).powi(2)) / len2).sqrt() }; + let kind = if g.kind == GradientKind::Linear { "linear" } else { "radial" }; + let stops: Vec = g + .sorted_stops() + .iter() + .map(|s| json!({"color": hex(s.color), "opacity": num(f64::from(s.color.a)), "offset": num(f64::from(s.position))})) + .collect(); + json!({"type": kind, "startX": num(fx), "startY": num(fy), "endX": num(tx), "endY": num(ty), "width": num(width), "stops": stops}) +} + +/// Infer the numeric font weight from a native style name. +fn weight_of(style: &str) -> u16 { + let s = style.to_ascii_lowercase().replace(['-', '_'], " "); + for (needle, weight) in [ + ("extra light", 200), + ("ultra light", 200), + ("extra bold", 800), + ("ultra bold", 800), + ("semi bold", 600), + ("demi bold", 600), + ("extralight", 200), + ("extrabold", 800), + ("semibold", 600), + ("thin", 100), + ("light", 300), + ("medium", 500), + ("bold", 700), + ("black", 900), + ("heavy", 900), + ] { + if s.contains(needle) { + return weight; + } + } + 400 +} + +/// Preserve an imported provider id or derive an offline family reference. +fn font_id(node: &Node, family: &str) -> String { + for key in ["penpot.googleFontRefs", "penpot.customFontRefs"] { + let refs = node.plugin_data.get(key).and_then(|raw| serde_json::from_str::(raw).ok()); + if let Some(id) = refs + .as_ref() + .and_then(Value::as_array) + .into_iter() + .flatten() + .find(|r| r.get("family").and_then(Value::as_str) == Some(family)) + .and_then(|r| r.get("id").and_then(Value::as_str)) + { + return id.to_owned(); + } + } + if family == "Source Sans Pro" { + return "sourcesanspro".into(); + } + let slug: String = family.to_ascii_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); + format!("gfont-{slug}") +} + +impl<'a> Exporter<'a> { + fn new(doc: &'a Document) -> Self { + let file_id = doc + .pages + .iter() + .filter_map(|p| doc.node(*p)?.plugin_data.get("penpot.fileId")) + .find(|id| uuid(id)) + .cloned() + .unwrap_or_else(|| derived_id(&format!("file:{}", doc.name))); + Exporter { + doc, + file_id, + page_ids: Vec::new(), + ids: BTreeMap::new(), + page_of: BTreeMap::new(), + component_ids: BTreeMap::new(), + media: BTreeMap::new(), + token_names: BTreeMap::new(), + unchanged_layouts: doc + .nodes() + .filter(|n| n.plugin_data.contains_key("penpot.fp.layout") && matches_fingerprint(n, "layout", layout_fingerprint(doc, n.id))) + .map(|n| n.id) + .collect(), + components: Vec::new(), + warnings: Vec::new(), + } + } + + fn warn(&mut self, message: impl Into) { + let message = message.into(); + if self.warnings.len() < 100 && !self.warnings.contains(&message) { + self.warnings.push(message); + } + } + + fn node(&self, id: NodeId) -> Option<&'a Node> { + self.doc.node(id) + } + + fn exported_children(&self, node: &Node) -> Vec { + let raw = svg_raw_source(node).is_some(); + let mut kids: Vec = node + .children + .iter() + .copied() + .filter(|c| self.node(*c).is_some_and(|n| penpot_type(n).is_some() && (!raw || n.plugin_data.contains_key("penpot.id")))) + .collect(); + if node.layout.as_ref().is_some_and(|l| matches!(l.mode, LayoutMode::Horizontal | LayoutMode::Vertical)) { + kids.reverse(); + } + kids + } + + fn assign_ids(&mut self) { + let mut pages_used = BTreeSet::new(); + let mut component_used = BTreeSet::new(); + for (index, &page) in self.doc.pages.iter().enumerate() { + let Some(page_node) = self.node(page) else { continue }; + let page_id = page_node + .plugin_data + .get("penpot.pageId") + .filter(|id| uuid(id) && !pages_used.contains(*id)) + .cloned() + .unwrap_or_else(|| derived_id(&format!("{}:page:{index}:{}", self.file_id, page.0))); + pages_used.insert(page_id.clone()); + self.page_ids.push((page, page_id.clone())); + let mut used = BTreeSet::from([ROOT_ID.to_owned()]); + let mut stack: Vec = page_node.children.iter().rev().copied().collect(); + while let Some(id) = stack.pop() { + let Some(node) = self.node(id) else { continue }; + if penpot_type(node).is_none() { + continue; + } + let mut pid = node.plugin_data.get("penpot.id").filter(|p| uuid(p) && !used.contains(*p)).cloned(); + let mut salt = 0; + while pid.is_none() { + let candidate = derived_id(&format!("{}:{page_id}:{}:{salt}", self.file_id, id.0)); + if !used.contains(&candidate) { + pid = Some(candidate); + } + salt += 1; + } + let pid = pid.unwrap_or_default(); + used.insert(pid.clone()); + self.ids.insert(id, pid); + self.page_of.insert(id, page_id.clone()); + if matches!(node.kind, NodeKind::Component { .. }) { + let cid = node + .plugin_data + .get("penpot.componentMasterId") + .filter(|c| uuid(c) && !component_used.contains(*c)) + .cloned() + .unwrap_or_else(|| derived_id(&format!("{}:component:{}", self.file_id, id.0))); + component_used.insert(cid.clone()); + self.component_ids.insert(id, cid); + } + stack.extend(self.exported_children(node).into_iter().rev()); + } + } + } + + fn write(mut self) -> Result<(Vec, Vec), FormatError> { + let first_page = self.doc.pages.first().and_then(|p| self.node(*p)); + let header: Value = first_page.and_then(|p| source_value(p, "file")).unwrap_or(Value::Null); + let features = header.get("features").cloned().unwrap_or_else(|| json!(DEFAULT_FEATURES)); + let migrations = if header.is_null() { Some(json!(MIGRATIONS)) } else { header.get("migrations").cloned() }; + let version = header.get("version").cloned().unwrap_or_else(|| json!(67)); + let options = header.get("options").cloned().unwrap_or_else(|| json!({"componentsV2": true, "baseFontSize": "16px"})); + let name: String = self.doc.name.chars().take(256).collect(); + let base = format!("files/{}", self.file_id); + let tokens = self.tokens_json(); + let mut entries: Vec<(String, Value)> = Vec::new(); + let mut file = json!({ + "id": self.file_id, + "name": name, + "features": features, + "version": version, + "revn": 1, + "vern": 0, + "isShared": header.get("isShared").cloned().unwrap_or(json!(false)), + "hasMediaTrimmed": false, + "options": options, + }); + if let (Some(migrations), Some(obj)) = (migrations, file.as_object_mut()) { + obj.insert("migrations".into(), migrations); + } + for key in ["createdAt", "modifiedAt"] { + if let (Some(value), Some(obj)) = (header.get(key), file.as_object_mut()) { + obj.insert(key.into(), value.clone()); + } + } + for (index, (page, page_id)) in self.page_ids.clone().into_iter().enumerate() { + let Some(page_node) = self.node(page) else { continue }; + let mut objects: Vec<(String, Value)> = Vec::new(); + let top: Vec = page_node.children.iter().copied().filter(|c| self.ids.contains_key(c)).collect(); + let mut top_ids = Vec::new(); + for child in top { + if let Some(id) = self.shape(child, ROOT_ID, ROOT_ID, &page_id, &mut objects) { + top_ids.push(json!(id)); + } + } + let background = match page_node.kind { + NodeKind::Page { background, .. } => background, + _ => Color::WHITE, + }; + let mut root = self.base_shape(ROOT_ID, "Root Frame", "frame", 0.0, 0.0, 0.01, 0.01, Affine::IDENTITY); + if let Some(obj) = root.as_object_mut() { + obj.insert("parentId".into(), json!(ROOT_ID)); + obj.insert("frameId".into(), json!(ROOT_ID)); + obj.insert("pageId".into(), json!(page_id)); + obj.insert("fills".into(), json!([{"fillColor": hex(background), "fillOpacity": 1}])); + obj.insert("strokes".into(), json!([])); + obj.insert("shapes".into(), Value::Array(top_ids)); + } + objects.push((ROOT_ID.into(), root)); + let page_json = self.page_json(page, &page_id, index, background); + entries.push((format!("{base}/pages/{page_id}.json"), page_json)); + for (id, object) in objects { + entries.push((format!("{base}/pages/{page_id}/{id}.json"), object)); + } + } + for component in std::mem::take(&mut self.components) { + if let Some(id) = component.get("id").and_then(Value::as_str) { + entries.push((format!("{base}/components/{id}.json"), component.clone())); + } + } + let mut blobs: Vec<(String, Vec)> = Vec::new(); + for media in self.media.values() { + entries.push(( + format!("{base}/media/{}.json", media.id), + json!({"id": media.id, "name": "image", "width": media.width, "height": media.height, "mtype": media.mtype, "mediaId": media.object, "isLocal": true}), + )); + entries.push(( + format!("objects/{}.json", media.object), + json!({"id": media.object, "size": media.bytes.len(), "contentType": media.mtype, "bucket": "file-media-object"}), + )); + blobs.push((format!("objects/{}.{}", media.object, media.ext), media.bytes.clone())); + } + let manifest = json!({ + "type": "penpot/export-files", + "version": 1, + "generatedBy": format!("figcraft/{}", env!("CARGO_PKG_VERSION")), + "refer": "penpot", + "files": [{"id": self.file_id, "name": name, "features": file.get("features").cloned().unwrap_or(Value::Null)}], + "relations": [], + }); + let mut buf = Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let mut put = |path: &str, bytes: &[u8]| -> Result<(), FormatError> { + z.start_file(path, json_options()).map_err(io_err)?; + z.write_all(bytes).map_err(io_err) + }; + put("manifest.json", &serde_json::to_vec_pretty(&manifest).map_err(io_err)?)?; + put(&format!("{base}.json"), &serde_json::to_vec_pretty(&file).map_err(io_err)?)?; + if let Some(tokens) = tokens { + put(&format!("{base}/tokens.json"), tokens.as_bytes())?; + } + for (path, value) in &entries { + put(path, &serde_json::to_vec_pretty(value).map_err(io_err)?)?; + } + for (path, bytes) in &blobs { + put(path, bytes)?; + } + z.finish().map_err(io_err)?; + } + Ok((buf.into_inner(), self.warnings)) + } + + fn page_json(&self, page: NodeId, page_id: &str, index: usize, background: Color) -> Value { + let mut page_json = json!({"id": page_id, "name": self.node(page).map(|n| n.name.clone()).unwrap_or_default(), "index": index, "background": hex(background)}); + let Some(node) = self.node(page) else { return page_json }; + let NodeKind::Page { flows, guides, .. } = &node.kind else { return page_json }; + let guides = if guides.is_empty() { &node.guides } else { guides }; + let mut flow_map = Map::new(); + for (i, flow) in flows.iter().enumerate() { + let Some(start) = self.ids.get(&flow.start) else { continue }; + let id = derived_id(&format!("{page_id}:flow:{i}:{}", flow.name)); + flow_map.insert(id.clone(), json!({"id": id, "name": flow.name, "startingFrame": start})); + } + let mut guide_map = Map::new(); + for (i, guide) in guides.iter().enumerate() { + let id = derived_id(&format!("{page_id}:guide:{i}")); + guide_map.insert(id.clone(), json!({"id": id, "axis": if guide.vertical { "x" } else { "y" }, "position": num(guide.offset)})); + } + let mut frame_guides = Vec::new(); + for (&nid, pid) in &self.ids { + if self.page_of.get(&nid).map(String::as_str) != Some(page_id) { + continue; + } + let Some(n) = self.node(nid) else { continue }; + if n.guides.is_empty() { + continue; + } + let origin = self.doc.world_transform(nid) * Point::ZERO; + for (i, guide) in n.guides.iter().enumerate() { + frame_guides.push(( + derived_id(&format!("{page_id}:{pid}:guide:{i}")), + guide.vertical, + if guide.vertical { origin.x } else { origin.y } + guide.offset, + pid.clone(), + )); + } + } + for (id, vertical, position, frame) in frame_guides { + guide_map.insert(id.clone(), json!({"id": id, "axis": if vertical { "x" } else { "y" }, "position": num(position), "frameId": frame})); + } + if let Some(obj) = page_json.as_object_mut() { + if !flow_map.is_empty() { + obj.insert("flows".into(), Value::Object(flow_map)); + } + if !guide_map.is_empty() { + obj.insert("guides".into(), Value::Object(guide_map)); + } + } + page_json + } + + fn base_shape(&self, id: &str, name: &str, typ: &str, x: f64, y: f64, w: f64, h: f64, linear: Affine) -> Value { + let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)]; + let center = Point::new(x + w / 2.0, y + h / 2.0); + let world = Affine::translate(center.to_vec2()) * linear * Affine::translate((-w / 2.0, -h / 2.0)); + let points: Vec = corners + .iter() + .map(|p| { + let q = world * *p; + json!({"x": num(q.x), "y": num(q.y)}) + }) + .collect(); + let [a, b, ..] = linear.as_coeffs(); + let mut rotation = b.atan2(a).to_degrees(); + if rotation < 0.0 { + rotation += 360.0; + } + if rotation.abs() < 1e-6 || (rotation - 360.0).abs() < 1e-6 { + rotation = 0.0; + } + let inverse = if linear.determinant().abs() > 1e-12 { linear.inverse() } else { Affine::IDENTITY }; + let flipped = linear.determinant() < 0.0; + json!({ + "id": id, + "name": name, + "type": typ, + "x": num(x), + "y": num(y), + "width": num(w), + "height": num(h), + "rotation": num(rotation), + "selrect": rect_json(x, y, w, h), + "points": points, + "transform": matrix(linear), + "transformInverse": matrix(inverse), + "flipX": if flipped { json!(true) } else { Value::Null }, + "flipY": Value::Null, + "proportionLock": false, + }) + } + + fn world_outline(&mut self, id: NodeId, depth: usize) -> BezPath { + let Some(node) = self.node(id) else { return BezPath::new() }; + if depth > 64 { + return BezPath::new(); + } + match &node.kind { + NodeKind::Group => { + let mut out = BezPath::new(); + for child in node.children.clone() { + out.extend(self.world_outline(child, depth + 1)); + } + out + } + NodeKind::BooleanOp { op } => { + let op = *op; + let operands: Vec<(BezPath, BoolOp)> = node + .children + .clone() + .into_iter() + .map(|c| { + let child_op = self.node(c).and_then(|n| n.bool_op).unwrap_or(op); + (figcraft_pathops::closed(&self.world_outline(c, depth + 1)), child_op) + }) + .collect(); + match figcraft_pathops::boolean_ops(operands.iter().map(|(p, o)| (p, *o))) { + Ok(path) => path, + Err(_) => { + self.warn(format!("Boolean layer '{}' could not be flattened; its operands were exported as one path", node.name)); + let mut out = BezPath::new(); + for (p, _) in operands { + out.extend(p); + } + out + } + } + } + NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => self.doc.world_transform(id) * network.stroke_path(), + _ => match figcraft_doc::geometry::shape_path(node) { + Some(p) => self.doc.world_transform(id) * p, + None => BezPath::new(), + }, + } + } + + fn shape(&mut self, id: NodeId, parent: &str, frame: &str, page_id: &str, objects: &mut Vec<(String, Value)>) -> Option { + let node = self.node(id)?; + let pid = self.ids.get(&id)?.clone(); + let typ = penpot_type(node)?; + let world = self.doc.world_transform(id); + let (w, h) = (node.width.max(0.01), node.height.max(0.01)); + let [a, b, c, d, ..] = world.as_coeffs(); + let linear = Affine::new([a, b, c, d, 0.0, 0.0]); + let center = world * Point::new(w / 2.0, h / 2.0); + let mut shape = self.base_shape(&pid, &node.name, typ, center.x - w / 2.0, center.y - h / 2.0, w, h, linear); + if matches!(typ, "path" | "bool") { + let path = self.world_outline(id, 0); + if let Some(obj) = shape.as_object_mut() { + obj.insert("content".into(), path_content(&path)); + } + } + let child_frame = if typ == "frame" { pid.clone() } else { frame.to_owned() }; + let kids = self.exported_children(node); + let mut child_ids = Vec::new(); + for child in &kids { + if let Some(cid) = self.shape(*child, &pid, &child_frame, page_id, objects) { + child_ids.push(cid); + } + } + let parent_node = node.parent.and_then(|p| self.node(p)); + let parent_layout = parent_node.and_then(|p| p.layout.as_ref()); + let mut fields = Map::new(); + fields.insert("parentId".into(), json!(parent)); + fields.insert("frameId".into(), json!(frame)); + fields.insert("pageId".into(), json!(page_id)); + fields.insert("fills".into(), Value::Array(self.paints(&node.fills, false))); + fields.insert("strokes".into(), Value::Array(self.strokes(node, typ))); + if !node.visible { + fields.insert("hidden".into(), json!(true)); + } + if node.locked { + fields.insert("blocked".into(), json!(true)); + } + if (node.opacity - 1.0).abs() > 1e-6 { + fields.insert("opacity".into(), num(f64::from(node.opacity))); + } + if let Some(name) = blend_name(node.blend).filter(|n| *n != "normal") { + fields.insert("blendMode".into(), json!(name)); + } + fields.insert("proportionLock".into(), json!(node.constrain_proportions)); + fields.insert( + "constraintsH".into(), + json!(match node.constraints.horizontal { + HConstraint::Left => "left", + HConstraint::Right => "right", + HConstraint::LeftRight => "leftright", + HConstraint::Center => "center", + HConstraint::Scale => "scale", + }), + ); + fields.insert( + "constraintsV".into(), + json!(match node.constraints.vertical { + VConstraint::Top => "top", + VConstraint::Bottom => "bottom", + VConstraint::TopBottom => "topbottom", + VConstraint::Center => "center", + VConstraint::Scale => "scale", + }), + ); + self.effects(node, &mut fields); + if matches!(typ, "rect" | "frame") { + for (i, key) in ["r1", "r2", "r3", "r4"].iter().enumerate() { + fields.insert((*key).into(), num(node.corners.radii.get(i).copied().unwrap_or(0.0))); + } + } + if matches!(typ, "frame" | "group" | "bool" | "svg-raw") { + fields.insert("shapes".into(), json!(child_ids)); + } + if typ == "bool" + && let NodeKind::BooleanOp { op } = node.kind + { + fields.insert( + "boolType".into(), + json!(match op { + BoolOp::Union => "union", + BoolOp::Subtract => "difference", + BoolOp::Intersect => "intersection", + BoolOp::Exclude => "exclude", + }), + ); + } + if typ == "frame" { + fields.insert("showContent".into(), json!(!node.clip)); + fields.insert("hideFillOnExport".into(), json!(false)); + if !node.grids.is_empty() { + fields.insert("grids".into(), self.grids(node)); + } + if matches!(node.kind, NodeKind::ComponentSet { .. }) { + fields.insert("isVariantContainer".into(), json!(true)); + } + self.layout(id, node, &kids, &mut fields); + } + if typ == "group" { + let first_is_mask = node.children.first().and_then(|c| self.node(*c)).is_some_and(|c| c.is_mask); + if first_is_mask { + fields.insert("maskedGroup".into(), json!(true)); + } + } + if node.is_mask && !parent_node.is_some_and(|p| matches!(p.kind, NodeKind::Group) && p.children.first() == Some(&id)) { + self.warn(format!( + "Mask '{}' is not the bottom layer of a group; Penpot has no equivalent, so it was exported as a plain layer", + node.name + )); + } + if parent_layout.is_some() || node.child_layout != figcraft_doc::ChildLayout::default() { + self.child_layout(node, &mut fields); + } + if let Some(parent) = parent_node + && self.unchanged_layouts.contains(&parent.id) + { + for key in [ + "layoutItemHSizing", + "layoutItemVSizing", + "layoutItemAbsolute", + "layoutItemMargin", + "layoutItemMarginType", + "layoutItemAlignSelf", + "layoutItemMinW", + "layoutItemMaxW", + "layoutItemMinH", + "layoutItemMaxH", + ] { + if let Some(value) = source_value(node, key) { + fields.insert(key.into(), value); + } + } + } + if typ == "text" + && let NodeKind::Text(text) = &node.kind + { + fields.insert("fills".into(), json!([])); + let unchanged = matches_fingerprint(node, "text", text_fingerprint(node)); + match (unchanged, source_value(node, "content")) { + (true, Some(content)) => { + fields.insert("content".into(), content); + if let Some(position) = source_value(node, "positionData") { + fields.insert("positionData".into(), position); + } + } + _ => { + fields.insert("content".into(), self.text_content(node, text)); + } + } + fields.insert( + "growType".into(), + json!(match text.auto_resize { + TextAutoResize::WidthAndHeight => "auto-width", + TextAutoResize::Height => "auto-height", + TextAutoResize::None | TextAutoResize::Truncate => "fixed", + }), + ); + } + if typ == "svg-raw" + && let Some(content) = svg_raw_source(node) + { + fields.insert("content".into(), content); + } + if let Some(interactions) = self.interactions(id, node) { + fields.insert("interactions".into(), interactions); + } + self.components(id, node, &pid, page_id, &mut fields); + if let Some(tokens) = self.applied_tokens(node) { + fields.insert("appliedTokens".into(), tokens); + } + for key in PASSTHROUGH_KEYS { + if let Some(value) = source_value(node, key) { + fields.insert((*key).into(), value); + } + } + if let Some(attrs) = self.svg_attrs(node, typ) { + fields.insert("svgAttrs".into(), attrs); + } + if let Some(obj) = shape.as_object_mut() { + obj.extend(fields); + } + objects.push((pid.clone(), shape)); + Some(pid) + } + + fn svg_attrs(&self, node: &Node, typ: &str) -> Option { + let mut attrs = source_value(node, "svgAttrs").and_then(|v| v.as_object().cloned()); + if !matches!(typ, "rect" | "circle" | "path" | "bool") { + return attrs.map(Value::Object); + } + let ancestor_fill = + self.doc.ancestors(node.id).iter().filter_map(|a| self.node(*a)).take_while(|a| !a.kind.is_frame_like()).any(|a| !a.fills.is_empty()); + if attrs.is_none() && (!node.fills.is_empty() || !ancestor_fill) { + return None; + } + let map = attrs.get_or_insert_with(Map::new); + if let Some(Value::Object(style)) = map.get_mut("style") { + style.remove("fill"); + } + if node.fills.is_empty() { + map.insert("fill".into(), json!("none")); + } else { + map.remove("fill"); + } + attrs.map(Value::Object) + } + + fn image_fill(&mut self, hash: &str, keep_aspect: bool) -> Option { + if !self.media.contains_key(hash) { + let data = self.doc.images.get(hash)?; + let (mtype, ext, bytes) = match image::guess_format(&data.bytes) { + Ok(image::ImageFormat::Png) => ("image/png", "png", data.bytes.to_vec()), + Ok(image::ImageFormat::Jpeg) => ("image/jpeg", "jpg", data.bytes.to_vec()), + Ok(image::ImageFormat::WebP) => ("image/webp", "webp", data.bytes.to_vec()), + Ok(image::ImageFormat::Gif) => ("image/gif", "gif", data.bytes.to_vec()), + _ => { + let decoded = image::load_from_memory(&data.bytes).ok()?; + let mut out = Cursor::new(Vec::new()); + decoded.write_to(&mut out, image::ImageFormat::Png).ok()?; + ("image/png", "png", out.into_inner()) + } + }; + self.media.insert( + hash.to_owned(), + Media { + id: derived_id(&format!("{}:media:{hash}", self.file_id)), + object: derived_id(&format!("{}:object:{hash}", self.file_id)), + mtype, + ext, + bytes, + width: data.width.max(1), + height: data.height.max(1), + }, + ); + } + let media = self.media.get(hash)?; + Some( + json!({"id": media.id, "width": media.width, "height": media.height, "mtype": media.mtype, "name": "image", "keepAspectRatio": keep_aspect}), + ) + } + + fn paint_fields(&mut self, paint: &Paint, stroke: bool) -> Option> { + let (color_key, gradient_key, image_key, opacity_key) = if stroke { + ("strokeColor", "strokeColorGradient", "strokeImage", "strokeOpacity") + } else { + ("fillColor", "fillColorGradient", "fillImage", "fillOpacity") + }; + let mut out = Map::new(); + match &paint.kind { + PaintKind::Solid { color } => { + out.insert(color_key.into(), json!(hex(*color))); + out.insert(opacity_key.into(), num(f64::from((paint.opacity * color.a).clamp(0.0, 1.0)))); + } + PaintKind::Gradient(g) => { + if !matches!(g.kind, GradientKind::Linear | GradientKind::Radial) { + self.warn("Angular and diamond gradients have no Penpot equivalent; exported as radial gradients"); + } + out.insert(gradient_key.into(), gradient_json(g)); + out.insert(opacity_key.into(), num(f64::from(paint.opacity.clamp(0.0, 1.0)))); + } + PaintKind::Image(img) => { + let keep_aspect = !(img.scale_mode == ImageScaleMode::Crop && img.crop.is_none_or(|c| c == [1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + if matches!(img.scale_mode, ImageScaleMode::Fit | ImageScaleMode::Tile) { + self.warn("Fit and tiled image fills have no Penpot equivalent; exported as fill images"); + } + let Some(image) = self.image_fill(&img.image, keep_aspect) else { + self.warn("An image fill whose image is missing from the document was left out"); + return None; + }; + out.insert(image_key.into(), image); + out.insert(opacity_key.into(), num(f64::from(paint.opacity.clamp(0.0, 1.0)))); + } + PaintKind::Pattern(_) => { + self.warn("Pattern fills have no Penpot equivalent and were left out"); + return None; + } + } + Some(out) + } + + fn paints(&mut self, paints: &[Paint], stroke: bool) -> Vec { + let mut out = Vec::new(); + for paint in paints.iter().rev() { + if !paint.visible && !stroke { + self.warn("Hidden fills have no Penpot equivalent and were left out"); + continue; + } + if let Some(mut fields) = self.paint_fields(paint, stroke) { + if stroke && !paint.visible { + fields.insert("hidden".into(), json!(true)); + } + out.push(Value::Object(fields)); + } + } + out + } + + fn strokes(&mut self, node: &Node, typ: &str) -> Vec { + if matches_fingerprint(node, "strokes", strokes_fingerprint(node)) + && let Some(Value::Array(source)) = source_value(node, "strokes") + && !source.iter().any(|s| s.get("strokeImage").is_some()) + { + return source; + } + let mut strokes = self.paints(&node.strokes, true); + let props = &node.stroke; + let w = props.weight; + let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6); + let style = if props.dashes.is_empty() { + "solid" + } else if near(&props.dashes, &[0.0, w + 5.0]) { + "dotted" + } else if near(&props.dashes, &[w + 5.0, w + 5.0, w + 1.0, w + 5.0]) { + "mixed" + } else { + if !near(&props.dashes, &[w + 10.0, w + 10.0]) { + self.warn("Custom dash patterns have no Penpot equivalent; exported as Penpot's dashed style"); + } + "dashed" + }; + let cap = |cap: StrokeCap, dotted: bool| -> Option<&'static str> { + match cap { + StrokeCap::Round if !dotted => Some("round"), + StrokeCap::Square => Some("square"), + StrokeCap::LineArrow => Some("line-arrow"), + StrokeCap::TriangleArrow | StrokeCap::ReversedTriangle => Some("triangle-arrow"), + StrokeCap::CircleFilled => Some("circle-marker"), + StrokeCap::DiamondFilled => Some("diamond-marker"), + _ => None, + } + }; + let open = typ == "path" + && figcraft_doc::geometry::stroke_geometry(node).is_some_and(|p| !p.elements().iter().any(|e| matches!(e, PathEl::ClosePath))); + let alignment = match props.align { + StrokeAlign::Inside => "inner", + StrokeAlign::Center => "center", + StrokeAlign::Outside => "outer", + }; + for stroke in &mut strokes { + let Some(obj) = stroke.as_object_mut() else { continue }; + obj.insert("strokeWidth".into(), num(w)); + obj.insert("strokeAlignment".into(), json!(alignment)); + obj.insert("strokeStyle".into(), json!(style)); + if open { + if let Some(c) = cap(props.cap_start, style == "dotted") { + obj.insert("strokeCapStart".into(), json!(c)); + } + if let Some(c) = cap(props.cap_end, style == "dotted") { + obj.insert("strokeCapEnd".into(), json!(c)); + } + } + } + if props.sides.is_some() && !strokes.is_empty() { + self.warn("Per-side stroke widths have no Penpot equivalent; exported with one width"); + } + strokes + } + + fn effects(&mut self, node: &Node, fields: &mut Map) { + let mut shadows = Vec::new(); + for (i, effect) in node.effects.iter().enumerate() { + let id = derived_id(&format!("{}:{}:effect:{i}", self.file_id, node.id.0)); + match &effect.kind { + EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => { + let style = if matches!(effect.kind, EffectKind::DropShadow(_)) { "drop-shadow" } else { "inner-shadow" }; + shadows.push(json!({ + "id": id, + "style": style, + "offsetX": num(s.offset.0), + "offsetY": num(s.offset.1), + "blur": num(s.radius), + "spread": num(s.spread), + "hidden": !effect.visible, + "color": {"color": hex(s.color), "opacity": num(f64::from(s.color.a))}, + })); + } + EffectKind::LayerBlur(b) => { + fields.insert("blur".into(), json!({"id": id, "type": "layer-blur", "value": num(b.radius), "hidden": !effect.visible})); + } + EffectKind::BackgroundBlur(b) => { + fields.insert( + "backgroundBlur".into(), + json!({"id": id, "type": "background-blur", "value": num(b.radius), "hidden": !effect.visible}), + ); + } + _ => self.warn("Noise, texture and glass effects have no Penpot equivalent and were left out"), + } + } + if !shadows.is_empty() { + fields.insert("shadow".into(), Value::Array(shadows)); + } + } + + fn grids(&self, node: &Node) -> Value { + let grids: Vec = node + .grids + .iter() + .map(|g| { + let color = json!({"color": hex(g.color), "opacity": num(f64::from(g.color.a))}); + match g.kind { + GridKind::Grid => json!({"type": "square", "display": g.visible, "params": {"size": num(g.size), "color": color}}), + GridKind::Columns | GridKind::Rows => { + let align = match g.align { + GridAlign::Stretch => "stretch", + GridAlign::Min => "left", + GridAlign::Center => "center", + GridAlign::Max => "right", + }; + json!({ + "type": if g.kind == GridKind::Columns { "column" } else { "row" }, + "display": g.visible, + "params": {"size": g.count, "itemLength": num(g.size), "gutter": num(g.gutter), "margin": num(g.offset), "type": align, "color": color}, + }) + } + } + }) + .collect(); + Value::Array(grids) + } + + fn layout(&mut self, id: NodeId, node: &Node, kids: &[NodeId], fields: &mut Map) { + let unchanged = self.unchanged_layouts.contains(&id); + if unchanged { + let source: Vec<(String, Value)> = SOURCE_LAYOUT_KEYS.iter().filter_map(|k| Some(((*k).to_owned(), source_value(node, k)?))).collect(); + if source.iter().any(|(k, _)| k == "layout") + && (node.layout.as_ref().is_none_or(|al| al.mode != LayoutMode::Grid) || source.iter().any(|(k, _)| k == "layoutGridCells")) + { + fields.extend(source); + return; + } + } + let Some(al) = &node.layout else { return }; + let (row_gap, column_gap) = match al.mode { + LayoutMode::Horizontal => (al.cross_gap, al.gap), + LayoutMode::Vertical => (al.gap, al.cross_gap), + LayoutMode::Grid => (al.cross_gap, al.gap), + }; + let [p1, p2, p3, p4] = al.padding; + fields.insert("layoutGap".into(), json!({"rowGap": num(row_gap), "columnGap": num(column_gap)})); + fields.insert("layoutGapType".into(), json!("multiple")); + fields.insert("layoutPadding".into(), json!({"p1": num(p1), "p2": num(p2), "p3": num(p3), "p4": num(p4)})); + fields.insert("layoutPaddingType".into(), json!("multiple")); + let justify = match al.main_align { + MainAlign::Min => "start", + MainAlign::Center => "center", + MainAlign::Max => "end", + MainAlign::SpaceBetween => "space-between", + MainAlign::SpaceAround => "space-around", + MainAlign::SpaceEvenly => "space-evenly", + }; + let align = match al.cross_align { + CrossAlign::Min | CrossAlign::Baseline => "start", + CrossAlign::Center => "center", + CrossAlign::Max => "end", + }; + fields.insert("layoutAlignItems".into(), json!(align)); + match al.mode { + LayoutMode::Horizontal | LayoutMode::Vertical => { + let dir = if al.mode == LayoutMode::Horizontal { "row" } else { "column" }; + fields.insert("layout".into(), json!("flex")); + fields.insert("layoutFlexDir".into(), json!(dir)); + fields.insert("layoutWrapType".into(), json!(if al.wrap { "wrap" } else { "nowrap" })); + fields.insert("layoutJustifyContent".into(), json!(justify)); + fields.insert( + "layoutAlignContent".into(), + json!(match al.content_align { + ContentAlign::Auto => match al.cross_align { + CrossAlign::Center => "center", + CrossAlign::Max => "end", + _ => "start", + }, + ContentAlign::SpaceBetween => "space-between", + }), + ); + if al.cross_align == CrossAlign::Baseline { + self.warn("Baseline alignment has no Penpot equivalent; exported as start"); + } + } + LayoutMode::Grid => { + let track = |t: &GridTrack| match t.sizing { + Sizing::Fixed => json!({"type": "fixed", "value": num(t.value)}), + Sizing::Hug => json!({"type": "auto"}), + Sizing::Fill => json!({"type": "flex", "value": num(t.value.max(0.001))}), + }; + let mut columns: Vec = al.column_tracks.iter().map(track).collect(); + let mut rows: Vec = al.row_tracks.iter().map(track).collect(); + columns.resize(al.grid_columns.max(1) as usize, json!({"type": "flex", "value": 1})); + rows.resize(al.grid_rows.max(1) as usize, json!({"type": "flex", "value": 1})); + fields.insert("layout".into(), json!("grid")); + fields.insert("layoutGridDir".into(), json!("row")); + fields.insert( + "layoutJustifyItems".into(), + json!(match al.main_align { + MainAlign::Center => "center", + MainAlign::Max => "end", + _ => "start", + }), + ); + fields.insert("layoutJustifyContent".into(), json!("start")); + fields.insert("layoutAlignContent".into(), json!("start")); + let (cells, used_rows) = self.grid_cells(node, al, kids, columns.len()); + rows.resize(rows.len().max(used_rows), json!({"type": "flex", "value": 1})); + fields.insert("layoutGridColumns".into(), Value::Array(columns)); + fields.insert("layoutGridRows".into(), Value::Array(rows)); + fields.insert("layoutGridCells".into(), cells); + } + } + } + + fn grid_cells(&mut self, node: &Node, al: &figcraft_doc::AutoLayout, kids: &[NodeId], columns: usize) -> (Value, usize) { + let frame_id = self.ids.get(&node.id).cloned().unwrap_or_default(); + let flow: Vec = + node.children.iter().copied().filter(|c| kids.contains(c) && self.node(*c).is_some_and(|n| !n.child_layout.absolute)).collect(); + let (placed, rows) = figcraft_layout::grid_place(self.doc, al, &flow); + let mut taken = vec![vec![false; columns]; rows]; + let mut map = Map::new(); + let cell_align = |a: Option| match a { + Some(CellAlign::Min) => "start", + Some(CellAlign::Center) => "center", + Some(CellAlign::Max) => "end", + None => "auto", + }; + for cell in &placed { + for row in taken.iter_mut().skip(cell.row).take(cell.row_span) { + for slot in row.iter_mut().skip(cell.column).take(cell.column_span) { + *slot = true; + } + } + let Some(n) = self.node(cell.id) else { continue }; + let manual = n.child_layout.grid_row.is_some() && n.child_layout.grid_column.is_some(); + let id = derived_id(&format!("{frame_id}:cell:{}:{}", cell.row, cell.column)); + map.insert( + id.clone(), + json!({ + "id": id, + "row": cell.row + 1, + "rowSpan": cell.row_span, + "column": cell.column + 1, + "columnSpan": cell.column_span, + "position": if manual { "manual" } else { "auto" }, + "alignSelf": cell_align(n.child_layout.grid_align_v), + "justifySelf": cell_align(n.child_layout.grid_align_h), + "shapes": [self.ids.get(&cell.id)], + }), + ); + } + for (r, row) in taken.iter().enumerate() { + for (c, used) in row.iter().enumerate() { + if !used { + let id = derived_id(&format!("{frame_id}:cell:{r}:{c}")); + map.insert( + id.clone(), + json!({"id": id, "row": r + 1, "rowSpan": 1, "column": c + 1, "columnSpan": 1, "position": "auto", "alignSelf": "auto", "justifySelf": "auto", "shapes": []}), + ); + } + } + } + (Value::Object(map), rows) + } + + fn child_layout(&mut self, node: &Node, fields: &mut Map) { + let cl = &node.child_layout; + let sizing = |s: Sizing| match s { + Sizing::Fixed => "fix", + Sizing::Hug => "auto", + Sizing::Fill => "fill", + }; + fields.insert("layoutItemHSizing".into(), json!(sizing(cl.sizing_h))); + fields.insert("layoutItemVSizing".into(), json!(sizing(cl.sizing_v))); + if cl.absolute { + fields.insert("layoutItemAbsolute".into(), json!(true)); + } + for (key, value) in [("layoutItemMinW", cl.min_w), ("layoutItemMaxW", cl.max_w), ("layoutItemMinH", cl.min_h), ("layoutItemMaxH", cl.max_h)] { + if let Some(v) = value { + fields.insert(key.into(), num(v)); + } + } + } + + fn text_attrs(&mut self, node: &Node, style: &TextStyle, align: &str) -> Map { + let weight = weight_of(&style.font_style); + let italic = style.font_style.to_ascii_lowercase().contains("italic") || style.font_style.to_ascii_lowercase().contains("oblique"); + let variant = match (weight, italic) { + (400, false) => "regular".to_owned(), + (400, true) => "italic".to_owned(), + (w, true) => format!("{w}italic"), + (w, false) => w.to_string(), + }; + let line_height = match style.line_height { + LineHeight::Percent(p) => p / 100.0, + LineHeight::Pixels(px) if style.font_size > 0.0 => px / style.font_size, + _ => 1.2, + }; + let letter_spacing = match style.letter_spacing { + LetterSpacing::Pixels(px) => px, + LetterSpacing::Percent(p) => p * style.font_size / 100.0, + }; + let fills = match &style.fills { + Some(fills) => self.paints(fills, false), + None => self.paints(&node.fills, false), + }; + let mut out = Map::new(); + out.insert("fontFamily".into(), json!(style.font_family)); + out.insert("fontId".into(), json!(font_id(node, &style.font_family))); + out.insert("fontVariantId".into(), json!(variant)); + out.insert("fontSize".into(), json!(num_text(style.font_size))); + out.insert("fontWeight".into(), json!(weight.to_string())); + out.insert("fontStyle".into(), json!(if italic { "italic" } else { "normal" })); + out.insert("lineHeight".into(), json!(num_text(line_height))); + out.insert("letterSpacing".into(), json!(num_text(letter_spacing))); + out.insert( + "textDecoration".into(), + json!(match style.decoration { + TextDecoration::None => "none", + TextDecoration::Underline => "underline", + TextDecoration::Strikethrough => "line-through", + }), + ); + out.insert( + "textTransform".into(), + json!(match style.case { + TextCase::Upper => "uppercase", + TextCase::Lower => "lowercase", + TextCase::Title => "capitalize", + _ => "none", + }), + ); + out.insert("textAlign".into(), json!(align)); + out.insert("fills".into(), Value::Array(fills)); + out + } + + fn text_content(&mut self, node: &Node, text: &figcraft_doc::TextData) -> Value { + let align = match text.align_h { + TextAlignH::Left => "left", + TextAlignH::Center => "center", + TextAlignH::Right => "right", + TextAlignH::Justified => "justify", + }; + let chars: Vec = text.characters.chars().collect(); + let style_at = |i: usize| -> &TextStyle { text.runs.iter().rev().find(|r| r.start <= i && i < r.end).map_or(&text.style, |r| &r.style) }; + let mut paragraphs = Vec::new(); + let mut start = 0usize; + loop { + let end = (start..chars.len()).find(|&i| chars.get(i) == Some(&'\n')).unwrap_or(chars.len()); + let mut leaves = Vec::new(); + let mut i = start; + while i < end { + let style = style_at(i); + let mut j = i + 1; + while j < end && style_at(j) == style { + j += 1; + } + let mut leaf = self.text_attrs(node, style, align); + leaf.insert("text".into(), json!(chars.get(i..j).map(|s| s.iter().collect::()).unwrap_or_default())); + leaves.push(Value::Object(leaf)); + i = j; + } + let paragraph_style = if end > start { style_at(start) } else { style_at(start.saturating_sub(1)) }; + let mut paragraph = self.text_attrs(node, paragraph_style, align); + if leaves.is_empty() { + let mut leaf = paragraph.clone(); + leaf.insert("text".into(), json!("")); + leaves.push(Value::Object(leaf)); + } + paragraph.insert("type".into(), json!("paragraph")); + paragraph.insert("children".into(), Value::Array(leaves)); + paragraphs.push(Value::Object(paragraph)); + if end >= chars.len() { + break; + } + start = end + 1; + } + let mut root = Value::Object(self.text_attrs(node, &text.style, align)); + if let Some(obj) = root.as_object_mut() { + obj.remove("fills"); + obj.insert("type".into(), json!("root")); + obj.insert("children".into(), json!([{"type": "paragraph-set", "children": paragraphs}])); + } + let vertical = match text.align_v { + TextAlignV::Top => None, + TextAlignV::Middle => Some("center"), + TextAlignV::Bottom => Some("bottom"), + }; + if let (Some(v), Some(obj)) = (vertical, root.as_object_mut()) { + obj.insert("verticalAlign".into(), json!(v)); + } + root + } + + fn interactions(&mut self, id: NodeId, node: &Node) -> Option { + if let Some(source) = source_value(node, "interactions") + && matches_fingerprint(node, "interactions", fingerprint(&node.interactions)) + { + let exported: BTreeSet<&String> = self.ids.values().collect(); + let resolves = source.as_array().into_iter().flatten().all(|i| { + ["destination", "positionRelativeTo"] + .iter() + .all(|k| i.get(*k).and_then(Value::as_str).is_none_or(|d| exported.contains(&d.to_owned()))) + }); + if resolves { + return Some(source); + } + } + if node.interactions.is_empty() { + return None; + } + let mut out = Vec::new(); + for interaction in &node.interactions { + for action in &interaction.actions { + if let Some(item) = self.interaction(id, node, interaction, action) { + out.push(item); + } + } + } + Some(Value::Array(out)) + } + + fn interaction(&mut self, id: NodeId, node: &Node, interaction: &Interaction, action: &Action) -> Option { + let event = match interaction.trigger { + Trigger::OnClick | Trigger::MouseUp => "click", + Trigger::OnPress | Trigger::MouseDown => "mouse-press", + Trigger::OnHover => "mouse-over", + Trigger::MouseEnter => "mouse-enter", + Trigger::MouseLeave => "mouse-leave", + Trigger::AfterDelay { .. } => "after-delay", + _ => { + self.warn(format!("Prototype trigger on '{}' has no Penpot equivalent and was left out", node.name)); + return None; + } + }; + let mut out = Map::new(); + out.insert("eventType".into(), json!(event)); + if let Trigger::AfterDelay { ms } = interaction.trigger { + out.insert("delay".into(), json!(ms)); + } + let destination = |dest: &NodeId, exporter: &mut Self| -> Option { + let found = exporter.ids.get(dest).cloned(); + if found.is_none() { + exporter.warn(format!("A prototype link on '{}' points at a layer that is not exported; it was left out", node.name)); + } + found + }; + let overlay = |position: &OverlayPosition| match position { + OverlayPosition::Manual => "manual", + OverlayPosition::Center => "center", + OverlayPosition::TopLeft => "top-left", + OverlayPosition::TopRight => "top-right", + OverlayPosition::TopCenter => "top-center", + OverlayPosition::BottomLeft => "bottom-left", + OverlayPosition::BottomRight => "bottom-right", + OverlayPosition::BottomCenter => "bottom-center", + }; + let is_overlay = matches!(action, Action::OpenOverlay { .. }); + match action { + Action::Navigate { dest, preserve_scroll } => { + out.insert("actionType".into(), json!("navigate")); + out.insert("destination".into(), json!(destination(dest, self)?)); + out.insert("preserveScroll".into(), json!(preserve_scroll)); + } + Action::OpenOverlay { dest, position, close_on_outside, background, offset } => { + out.insert("actionType".into(), json!("open-overlay")); + out.insert("destination".into(), json!(destination(dest, self)?)); + out.insert("overlayPosType".into(), json!(overlay(position))); + let [x, y] = offset.unwrap_or([0.0, 0.0]); + out.insert("overlayPosition".into(), json!({"x": num(x), "y": num(y)})); + out.insert("closeClickOutside".into(), json!(close_on_outside)); + out.insert("backgroundOverlay".into(), json!(background.is_some())); + if *position == OverlayPosition::Manual + && let Some(relative) = self.ids.get(&id) + { + out.insert("positionRelativeTo".into(), json!(relative)); + } + } + Action::CloseOverlay => { + out.insert("actionType".into(), json!("close-overlay")); + } + Action::Back => { + out.insert("actionType".into(), json!("prev-screen")); + } + Action::OpenUrl { url } => { + out.insert("actionType".into(), json!("open-url")); + out.insert("url".into(), json!(url)); + } + _ => { + self.warn(format!("A prototype action on '{}' has no Penpot equivalent and was left out", node.name)); + return None; + } + } + if !matches!(action, Action::Back | Action::OpenUrl { .. }) + && let Some(animation) = self.animation(interaction, is_overlay) + { + out.insert("animation".into(), animation); + } + Some(Value::Object(out)) + } + + fn animation(&mut self, interaction: &Interaction, overlay: bool) -> Option { + let easing = match interaction.easing { + Easing::Linear => "linear", + Easing::EaseIn => "ease-in", + Easing::EaseOut => "ease-out", + Easing::EaseInOut => "ease-in-out", + _ => "ease", + }; + let direction = |d: Direction| match d { + Direction::Left => "left", + Direction::Right => "right", + Direction::Top => "up", + Direction::Bottom => "down", + }; + let duration = interaction.duration_ms; + Some(match interaction.transition { + Transition::Instant => return None, + Transition::Dissolve => json!({"animationType": "dissolve", "duration": duration, "easing": easing}), + Transition::SmartAnimate => { + self.warn("Smart animate has no Penpot equivalent; exported as dissolve"); + json!({"animationType": "dissolve", "duration": duration, "easing": easing}) + } + Transition::SlideIn { direction: d } | Transition::MoveIn { direction: d } => { + json!({"animationType": "slide", "duration": duration, "easing": easing, "way": "in", "direction": direction(d), "offsetEffect": false}) + } + Transition::SlideOut { direction: d } | Transition::MoveOut { direction: d } => { + json!({"animationType": "slide", "duration": duration, "easing": easing, "way": if overlay { "in" } else { "out" }, "direction": direction(d), "offsetEffect": false}) + } + Transition::Push { direction: d } if !overlay => { + json!({"animationType": "push", "duration": duration, "easing": easing, "direction": direction(d)}) + } + Transition::Push { direction: d } => { + json!({"animationType": "slide", "duration": duration, "easing": easing, "way": "in", "direction": direction(d), "offsetEffect": false}) + } + }) + } + + fn components(&mut self, id: NodeId, node: &Node, pid: &str, page_id: &str, fields: &mut Map) { + let inside_instance = self.doc.ancestors(id).iter().any(|a| self.node(*a).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))); + let inside_main = self.doc.ancestors(id).iter().any(|a| self.node(*a).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))); + match &node.kind { + NodeKind::Component { .. } => { + let Some(cid) = self.component_ids.get(&id).cloned() else { return }; + fields.insert("componentId".into(), json!(cid)); + fields.insert("componentFile".into(), json!(self.file_id)); + fields.insert("componentRoot".into(), json!(true)); + fields.insert("mainInstance".into(), json!(true)); + let source = source_value(node, "component").and_then(|v| v.as_object().cloned()).unwrap_or_default(); + let parent = node.parent.and_then(|p| self.node(p)); + let mut component = Map::new(); + component.insert("id".into(), json!(cid)); + component.insert("name".into(), source.get("name").cloned().unwrap_or_else(|| json!(node.name))); + component.insert("path".into(), source.get("path").cloned().unwrap_or_else(|| json!(""))); + component.insert("mainInstanceId".into(), json!(pid)); + component.insert("mainInstancePage".into(), json!(page_id)); + if let Some(set) = parent.filter(|p| matches!(p.kind, NodeKind::ComponentSet { .. })) + && let Some(set_id) = self.ids.get(&set.id) + { + let properties: Vec<(String, String)> = node + .name + .split(',') + .filter_map(|pair| { + let (k, v) = pair.split_once('=')?; + Some((k.trim().to_owned(), v.trim().to_owned())) + }) + .filter(|(k, _)| !k.is_empty()) + .collect(); + if !source.contains_key("name") { + component.insert("name".into(), json!(set.name)); + } + component.insert("variantId".into(), json!(set_id)); + component.insert( + "variantProperties".into(), + json!(properties.iter().map(|(k, v)| json!({"name": k, "value": v})).collect::>()), + ); + fields.insert("variantId".into(), json!(set_id)); + fields.insert("variantName".into(), json!(properties.iter().map(|(_, v)| v.as_str()).collect::>().join(", "))); + if let Some(original) = node.plugin_data.get("penpot.originalName") { + fields.insert("name".into(), json!(original)); + } + } + self.components.push(Value::Object(component)); + } + NodeKind::Instance { component, .. } => { + let Some(cid) = self.component_ids.get(component).cloned() else { + self.warn(format!("Instance '{}' points at a component that is not exported; it was exported as a plain board", node.name)); + return; + }; + fields.insert("componentId".into(), json!(cid)); + fields.insert("componentFile".into(), json!(self.file_id)); + if !inside_instance && !inside_main { + fields.insert("componentRoot".into(), json!(true)); + } + let reference = node.source.unwrap_or(*component); + if let Some(r) = self.ids.get(&reference) { + fields.insert("shapeRef".into(), json!(r)); + } + } + _ => { + if inside_instance && let Some(r) = node.source.and_then(|s| self.ids.get(&s)) { + fields.insert("shapeRef".into(), json!(r)); + } else if !inside_instance { + self.unlinked_copy(node, fields); + } + } + } + if inside_instance || matches!(node.kind, NodeKind::Instance { .. }) { + let mut touched: BTreeSet = source_value(node, "touched") + .and_then(|v| v.as_array().cloned()) + .into_iter() + .flatten() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(); + for group in &node.overrides { + let mapped = match group.as_str() { + "fills" => "fill-group", + "strokes" | "strokeWeight" | "stroke" => "stroke-group", + "characters" | "text" | "textStyle" => "content-group", + "visible" => "visibility-group", + "name" => "name-group", + "effects" => "shadow-group", + "opacity" | "blend" => "layer-group", + "cornerRadius" | "corners" => "radius-group", + "width" | "height" | "size" | "transform" => "geometry-group", + _ => continue, + }; + touched.insert(mapped.into()); + } + if !touched.is_empty() { + fields.insert("touched".into(), json!(touched)); + } + } + } + + fn unlinked_copy(&mut self, node: &Node, fields: &mut Map) { + let component = node.plugin_data.get("penpot.componentId").filter(|c| self.component_ids.values().any(|v| v == *c)); + let reference = node.plugin_data.get("penpot.shapeRef").filter(|r| self.ids.values().any(|v| v == *r)); + if let Some(component) = component { + fields.insert("componentId".into(), json!(component)); + fields.insert("componentFile".into(), json!(self.file_id)); + match source_value(node, "componentRoot") { + Some(root) => { + fields.insert("componentRoot".into(), root); + } + None if node.plugin_data.contains_key("penpot.componentLinkStatus") => { + fields.insert("componentRoot".into(), json!(true)); + } + None => {} + } + } + if let Some(reference) = reference { + fields.insert("shapeRef".into(), json!(reference)); + if let Some(touched) = source_value(node, "touched") { + fields.insert("touched".into(), touched); + } + } + if node.plugin_data.contains_key("penpot.componentId") && component.is_none() { + self.warn("Some component copies point at components that are not in the exported file; they were exported as plain layers"); + } + } + + fn token_name(&mut self, variable: &str) -> Option { + if let Some(name) = self.token_names.get(variable) { + return Some(name.clone()); + } + let var = self.doc.variables.get(variable)?; + let name = if variable.starts_with("penpot:tokens:") { + var.name.split_once('/').map_or(var.name.clone(), |(_, n)| n.to_owned()) + } else { + var.name.split('/').map(|s| s.trim().replace(' ', "-")).filter(|s| !s.is_empty()).collect::>().join(".") + }; + self.token_names.insert(variable.to_owned(), name.clone()); + Some(name) + } + + fn applied_tokens(&mut self, node: &Node) -> Option { + let mut out = source_value(node, "appliedTokens").and_then(|v| v.as_object().cloned()).unwrap_or_default(); + let vertical = node.layout.as_ref().is_some_and(|l| l.mode == LayoutMode::Vertical); + for (field, variable) in &node.bound { + let attr = match field.as_str() { + "fills/0/color" => "fill", + "strokes/0/color" => "strokeColor", + "width" => "width", + "height" => "height", + "paddingTop" => "p1", + "paddingRight" => "p2", + "paddingBottom" => "p3", + "paddingLeft" => "p4", + "gap" if vertical => "rowGap", + "gap" => "columnGap", + "crossGap" if vertical => "columnGap", + "crossGap" => "rowGap", + "topLeftRadius" => "r1", + "topRightRadius" => "r2", + "bottomRightRadius" => "r3", + "bottomLeftRadius" => "r4", + "strokeWeight" => "strokeWidth", + "fontSize" => "fontSize", + "letterSpacing" => "letterSpacing", + "fontFamily" => "fontFamily", + _ => continue, + }; + if let Some(name) = self.token_name(variable) { + out.insert(attr.into(), json!(name)); + } + } + (!out.is_empty()).then_some(Value::Object(out)) + } + + fn tokens_json(&mut self) -> Option { + let first = self.doc.pages.first().and_then(|p| self.node(*p)); + let source = first.and_then(|p| p.plugin_data.iter().find(|(k, _)| k.starts_with("penpot.tokens.")).map(|(_, v)| v.clone())); + if let Some(source) = source.filter(|s| serde_json::from_str::(s).is_ok()) { + if first.and_then(|p| p.plugin_data.get("penpot.fp.tokens")) != tokens_fingerprint(self.doc).as_ref() { + self.warn("Variables edited since the Penpot import are not exported; the file keeps its original Penpot tokens"); + } + return Some(source); + } + let doc = self.doc; + let variables = &doc.variables; + if variables.variables.is_empty() { + return None; + } + let mut root = Map::new(); + let mut themes = Vec::new(); + let mut order = Vec::new(); + let mut active_themes = Vec::new(); + let mut active_sets = Vec::new(); + for collection in &variables.collections { + let many = collection.modes.len() > 1; + for (index, mode) in collection.modes.iter().enumerate() { + let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() }; + let mut tokens = Map::new(); + for var in variables.variables.iter().filter(|v| v.collection == collection.id) { + let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue }; + let (kind, value) = match value { + VariableValue::Alias(target) => { + let Some(target_name) = self.token_name(target) else { continue }; + let kind = match var.kind { + VariableType::Color => "color", + VariableType::Number => "number", + VariableType::String => "string", + VariableType::Boolean => "boolean", + }; + (kind, json!(format!("{{{target_name}}}"))) + } + VariableValue::Color(c) => { + let [r, g, b, a] = c.to_rgba8(); + ("color", json!(if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") })) + } + VariableValue::Number(n) => ("number", json!(num_text(*n))), + VariableValue::String(s) => ("string", json!(s)), + VariableValue::Boolean(b) => ("boolean", json!(b.to_string())), + }; + let Some(name) = self.token_name(&var.id) else { continue }; + let parts: Vec<&str> = name.split('.').collect(); + insert_token(&mut tokens, &parts, json!({"$value": value, "$type": kind, "$description": var.description})); + } + root.insert(set_name.clone(), Value::Object(tokens)); + order.push(set_name.clone()); + if many { + let mut selected = Map::new(); + selected.insert(set_name.clone(), json!("enabled")); + themes.push(json!({ + "id": derived_id(&format!("{}:theme:{}:{}", self.file_id, collection.id, mode.id)), + "name": mode.name, + "group": collection.name, + "description": "", + "isSource": false, + "selectedTokenSets": selected, + })); + if index == 0 { + active_themes.push(format!("{}/{}", collection.name, mode.name)); + active_sets.push(set_name); + } + } else { + active_sets.push(set_name); + } + } + } + root.insert("$themes".into(), Value::Array(themes)); + root.insert("$metadata".into(), json!({"tokenSetOrder": order, "activeThemes": active_themes, "activeSets": active_sets})); + serde_json::to_string_pretty(&Value::Object(root)).ok() + } +} diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs new file mode 100644 index 000000000..917d7b61b --- /dev/null +++ b/crates/format/src/penpot/export_tests.rs @@ -0,0 +1,889 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Cursor, Read}; + +use figcraft_doc::color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind}; +use figcraft_doc::geom::{Affine, Point, Shape}; +use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger}; +use figcraft_doc::{ + AutoLayout, Blur, Collection, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId, NodeKind, + Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue, VectorNetwork, +}; +use serde_json::{Value, json}; + +use super::{ROOT_ID, export, import}; + +fn entries(bytes: &[u8]) -> BTreeMap { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = BTreeMap::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_owned(); + if name.ends_with(".json") { + let mut text = String::new(); + f.read_to_string(&mut text).unwrap(); + out.insert(name, serde_json::from_str(&text).unwrap()); + } + } + out +} + +fn shape<'a>(entries: &'a BTreeMap, name: &str) -> &'a Value { + entries + .iter() + .filter(|(path, _)| path.matches('/').count() == 4) + .map(|(_, v)| v) + .find(|v| v["name"] == name) + .unwrap_or_else(|| panic!("no shape named {name}")) +} + +fn named(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")).id +} + +fn roundtrip(doc: &Document) -> (Document, BTreeMap, Vec) { + let (bytes, _) = export(doc).unwrap(); + let (back, _) = import(&bytes).unwrap(); + (back, entries(&bytes), bytes) +} + +fn page(doc: &Document) -> NodeId { + doc.first_page().unwrap() +} + +#[test] +fn automatic_wrapped_line_alignment_follows_the_cross_axis() { + for (cross_align, expected) in [(CrossAlign::Min, "start"), (CrossAlign::Center, "center"), (CrossAlign::Max, "end")] { + let mut doc = Document::new("Wrapped alignment"); + let p = page(&doc); + let frame = add(&mut doc, p, NodeKind::Frame, "Wrapped", 0.0, 0.0, 100.0, 100.0); + doc.node_mut(frame).unwrap().layout = Some(AutoLayout { wrap: true, cross_align, ..AutoLayout::default() }); + let exported = entries(&export(&doc).unwrap().0); + assert_eq!(shape(&exported, "Wrapped")["layoutAlignContent"], expected); + } +} + +fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let id = doc.create(parent, kind, name, x, y, w, h).unwrap(); + doc.node_mut(id).unwrap().fills.clear(); + id +} + +fn world_bounds(doc: &Document, id: NodeId) -> [f64; 4] { + let n = doc.node(id).unwrap(); + let xf = doc.world_transform(id); + let pts = [Point::new(0.0, 0.0), Point::new(n.width, 0.0), Point::new(n.width, n.height), Point::new(0.0, n.height)].map(|p| xf * p); + let xs = pts.map(|p| p.x); + let ys = pts.map(|p| p.y); + [ + xs.iter().cloned().fold(f64::MAX, f64::min), + ys.iter().cloned().fold(f64::MAX, f64::min), + xs.iter().cloned().fold(f64::MIN, f64::max), + ys.iter().cloned().fold(f64::MIN, f64::max), + ] +} + +fn outline_bounds(doc: &Document, id: NodeId) -> [f64; 4] { + let n = doc.node(id).unwrap(); + let path = match &n.kind { + NodeKind::Vector { network } => network.stroke_path(), + _ => figcraft_doc::geometry::shape_path(n).unwrap(), + }; + let b = (doc.world_transform(id) * path).bounding_box(); + [b.x0, b.y0, b.x1, b.y1] +} + +fn close(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01) +} + +#[test] +fn pages_boards_and_groups_keep_their_tree_and_ids() { + let mut doc = Document::new("Tree"); + let p1 = page(&doc); + if let NodeKind::Page { background, .. } = &mut doc.node_mut(p1).unwrap().kind { + *background = Color::from_hex("#123456").unwrap(); + } + let p2 = doc.add_page("Second"); + let board = add(&mut doc, p1, NodeKind::Frame, "Board", 10.0, 20.0, 300.0, 200.0); + doc.node_mut(board).unwrap().clip = false; + let inner = add(&mut doc, board, NodeKind::Frame, "Inner", 10.0, 10.0, 100.0, 100.0); + let group = add(&mut doc, inner, NodeKind::Group, "Group", 0.0, 0.0, 50.0, 50.0); + add(&mut doc, group, NodeKind::Rectangle, "Rect", 5.0, 5.0, 40.0, 30.0); + add(&mut doc, p2, NodeKind::Rectangle, "Loose", 0.0, 0.0, 10.0, 10.0); + + let (back, e, bytes) = roundtrip(&doc); + assert_eq!(e["manifest.json"]["type"], "penpot/export-files"); + let file = e.iter().find(|(k, _)| k.matches('/').count() == 1).unwrap().1; + assert_eq!(file["version"], 67); + assert!(file["migrations"].as_array().unwrap().iter().any(|m| m == "0026-fix-svg-raw-shapes-uuids")); + let root = e.iter().find(|(k, _)| k.ends_with(&format!("{ROOT_ID}.json"))).unwrap().1; + assert_eq!(root["fills"][0]["fillColor"], "#123456"); + let rect = shape(&e, "Rect"); + assert_eq!(rect["parentId"], shape(&e, "Group")["id"]); + assert_eq!(rect["frameId"], shape(&e, "Inner")["id"]); + assert_eq!(shape(&e, "Inner")["frameId"], shape(&e, "Board")["id"]); + assert_eq!(shape(&e, "Board")["showContent"], true); + + assert_eq!(back.pages.len(), 2); + assert_eq!(back.node(back.pages[1]).unwrap().name, "Second"); + assert!(close(world_bounds(&back, named(&back, "Rect")), world_bounds(&doc, named(&doc, "Rect")))); + assert!(matches!(back.node(named(&back, "Group")).unwrap().kind, NodeKind::Group)); + assert!(!back.node(named(&back, "Board")).unwrap().clip); + + let (again, _) = export(&back).unwrap(); + let (twice, _) = export(&back).unwrap(); + assert_eq!(again, twice); + let ids_before: BTreeSet = e.keys().cloned().collect(); + let ids_after: BTreeSet = entries(&again).keys().cloned().collect(); + assert_eq!(ids_before, ids_after, "re-exporting an imported file keeps every page and shape id"); + assert!(!bytes.is_empty()); +} + +#[test] +fn rectangles_ellipses_and_paths_keep_their_geometry() { + let mut doc = Document::new("Shapes"); + let p = page(&doc); + let rect = add(&mut doc, p, NodeKind::Rectangle, "Rotated", 100.0, 100.0, 80.0, 40.0); + { + let n = doc.node_mut(rect).unwrap(); + n.transform = Affine::translate((100.0, 100.0)) * Affine::rotate(30f64.to_radians()); + n.corners.radii = [1.0, 2.0, 3.0, 4.0]; + } + add(&mut doc, p, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Circle", 0.0, 0.0, 30.0, 20.0); + add(&mut doc, p, NodeKind::Star { count: 5, inner_ratio: 0.4 }, "Star", 200.0, 0.0, 50.0, 50.0); + let network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 30.0)], false); + let line = add(&mut doc, p, NodeKind::Vector { network }, "Open", 300.0, 0.0, 40.0, 30.0); + doc.node_mut(line).unwrap().strokes = vec![Paint::solid(Color::BLACK)]; + + let (back, e, _) = roundtrip(&doc); + assert_eq!(shape(&e, "Rotated")["type"], "rect"); + assert_eq!(shape(&e, "Rotated")["r3"], 3); + assert!((shape(&e, "Rotated")["rotation"].as_f64().unwrap() - 30.0).abs() < 1e-6); + assert_eq!(shape(&e, "Circle")["type"], "circle"); + assert_eq!(shape(&e, "Star")["type"], "path"); + assert_eq!(shape(&e, "Open")["content"].as_array().unwrap().len(), 3); + for name in ["Rotated", "Circle", "Star", "Open"] { + assert!(close(outline_bounds(&back, named(&back, name)), outline_bounds(&doc, named(&doc, name))), "{name} moved"); + } + let r = back.node(named(&back, "Rotated")).unwrap(); + assert_eq!(r.corners.radii, [1.0, 2.0, 3.0, 4.0]); + let [a, b, ..] = doc.world_transform(rect).as_coeffs(); + let [a2, b2, ..] = back.world_transform(r.id).as_coeffs(); + assert!((a - a2).abs() < 1e-6 && (b - b2).abs() < 1e-6); + assert!(matches!(back.node(named(&back, "Circle")).unwrap().kind, NodeKind::Ellipse { .. })); +} + +#[test] +fn fills_and_strokes_keep_their_order_and_styles() { + let mut doc = Document::new("Paints"); + let p = page(&doc); + let id = add(&mut doc, p, NodeKind::Rectangle, "Painted", 0.0, 0.0, 100.0, 50.0); + let mut linear = Gradient { + kind: GradientKind::Linear, + stops: vec![ + ColorStop { position: 0.0, color: Color::from_hex("#ff0000").unwrap() }, + ColorStop { position: 1.0, color: Color::from_hex("#0000ff80").unwrap() }, + ], + from: (0.0, 0.5), + to: (1.0, 0.5), + width: (0.0, 0.0), + }; + linear.square_linear_width(1.0, 1.0); + let radial = Gradient { kind: GradientKind::Radial, from: (0.5, 0.5), to: (1.0, 0.5), width: (0.5, 0.75), ..linear.clone() }; + { + let n = doc.node_mut(id).unwrap(); + let mut bottom = Paint::solid(Color::from_hex("#00ff00").unwrap()); + bottom.opacity = 0.5; + n.fills = vec![bottom, Paint::gradient(linear), Paint::gradient(radial)]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 2.0; + n.stroke.align = StrokeAlign::Outside; + n.stroke.dashes = vec![12.0, 12.0]; + } + + let (back, e, _) = roundtrip(&doc); + let s = shape(&e, "Painted"); + assert_eq!(s["fills"][0]["fillColorGradient"]["type"], "radial"); + assert_eq!(s["fills"][2]["fillColor"], "#00ff00"); + assert_eq!(s["fills"][2]["fillOpacity"], 0.5); + assert_eq!(s["strokes"][0]["strokeStyle"], "dashed"); + assert_eq!(s["strokes"][0]["strokeAlignment"], "outer"); + assert!(s["strokes"][0].get("strokeJoin").is_none(), "Penpot strokes are a closed schema with no join"); + + let n = back.node(named(&back, "Painted")).unwrap(); + let kinds: Vec<&str> = n + .fills + .iter() + .map(|f| match &f.kind { + PaintKind::Solid { .. } => "solid", + PaintKind::Gradient(g) if g.kind == GradientKind::Radial => "radial", + PaintKind::Gradient(_) => "linear", + _ => "other", + }) + .collect(); + assert_eq!(kinds, ["solid", "linear", "radial"]); + assert!((n.fills[0].opacity - 0.5).abs() < 1e-6); + if let PaintKind::Gradient(g) = &n.fills[2].kind { + assert!((g.width.0 - 0.5).abs() < 1e-6 && (g.width.1 - 0.75).abs() < 1e-6); + } + if let PaintKind::Gradient(g) = &n.fills[1].kind { + assert!((g.stops[1].color.a - 128.0 / 255.0).abs() < 0.01); + } + assert_eq!(n.stroke.weight, 2.0); + assert_eq!(n.stroke.align, StrokeAlign::Outside); + assert_eq!(n.stroke.dashes, vec![12.0, 12.0]); +} + +#[test] +fn open_path_caps_and_dotted_strokes_survive() { + let mut doc = Document::new("Caps"); + let p = page(&doc); + let network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0)], false); + let id = add(&mut doc, p, NodeKind::Vector { network }, "Arrow", 0.0, 0.0, 50.0, 1.0); + { + let n = doc.node_mut(id).unwrap(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 1.0; + n.stroke.cap_end = StrokeCap::TriangleArrow; + n.stroke.dashes = vec![0.0, 6.0]; + } + let (back, e, _) = roundtrip(&doc); + let stroke = &shape(&e, "Arrow")["strokes"][0]; + assert_eq!(stroke["strokeStyle"], "dotted"); + assert_eq!(stroke["strokeCapEnd"], "triangle-arrow"); + let n = back.node(named(&back, "Arrow")).unwrap(); + assert_eq!(n.stroke.cap_end, StrokeCap::TriangleArrow); + assert_eq!(n.stroke.dashes, vec![0.0, 6.0]); +} + +#[test] +fn shadows_and_blurs_round_trip() { + let mut doc = Document::new("Effects"); + let p = page(&doc); + let id = add(&mut doc, p, NodeKind::Rectangle, "Shadowed", 0.0, 0.0, 50.0, 50.0); + let shadow = Shadow { color: Color::from_hex("#00000040").unwrap(), offset: (2.0, 4.0), radius: 8.0, spread: 1.0, ..Shadow::default() }; + doc.node_mut(id).unwrap().effects = vec![ + Effect { kind: EffectKind::DropShadow(shadow.clone()), visible: true }, + Effect { kind: EffectKind::InnerShadow(shadow), visible: false }, + Effect { kind: EffectKind::LayerBlur(Blur::uniform(3.0)), visible: true }, + Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(6.0)), visible: true }, + ]; + let (back, e, _) = roundtrip(&doc); + let s = shape(&e, "Shadowed"); + assert_eq!(s["shadow"][1]["style"], "inner-shadow"); + assert_eq!(s["blur"]["type"], "layer-blur"); + assert_eq!(s["backgroundBlur"]["type"], "background-blur"); + let effects = &back.node(named(&back, "Shadowed")).unwrap().effects; + assert_eq!(effects.len(), 4); + assert!(matches!(&effects[0].kind, EffectKind::DropShadow(s) if s.offset == (2.0, 4.0) && s.radius == 8.0 && (s.color.a - 0.25).abs() < 0.01)); + assert!(matches!(&effects[1].kind, EffectKind::InnerShadow(_)) && !effects[1].visible); + assert!(matches!(&effects[2].kind, EffectKind::LayerBlur(b) if b.radius == 3.0)); + assert!(matches!(&effects[3].kind, EffectKind::BackgroundBlur(b) if b.radius == 6.0)); +} + +#[test] +fn flex_layout_round_trips_with_its_child_order() { + let mut doc = Document::new("Flex"); + let p = page(&doc); + let frame = add(&mut doc, p, NodeKind::Frame, "Row", 0.0, 0.0, 300.0, 100.0); + for name in ["First", "Second", "Third"] { + add(&mut doc, frame, NodeKind::Rectangle, name, 0.0, 0.0, 40.0, 40.0); + } + let second = named(&doc, "Second"); + doc.node_mut(second).unwrap().child_layout.sizing_h = Sizing::Fill; + doc.node_mut(frame).unwrap().layout = Some(AutoLayout { + mode: LayoutMode::Horizontal, + gap: 12.0, + cross_gap: 6.0, + padding: [1.0, 2.0, 3.0, 4.0], + main_align: MainAlign::Center, + cross_align: CrossAlign::Center, + wrap: true, + strokes_included: false, + ..AutoLayout::default() + }); + + let (back, e, _) = roundtrip(&doc); + let s = shape(&e, "Row"); + assert_eq!(s["layout"], "flex"); + assert_eq!(s["layoutFlexDir"], "row"); + assert_eq!(s["layoutGap"], json!({"rowGap": 6, "columnGap": 12})); + assert_eq!(s["layoutPadding"], json!({"p1": 1, "p2": 2, "p3": 3, "p4": 4})); + assert_eq!(shape(&e, "Second")["layoutItemHSizing"], "fill"); + + let row = back.node(named(&back, "Row")).unwrap(); + let al = row.layout.as_ref().unwrap(); + assert_eq!((al.mode, al.gap, al.cross_gap, al.padding, al.wrap), (LayoutMode::Horizontal, 12.0, 6.0, [1.0, 2.0, 3.0, 4.0], true)); + assert_eq!((al.main_align, al.cross_align), (MainAlign::Center, CrossAlign::Center)); + let order: Vec = row.children.iter().map(|c| back.node(*c).unwrap().name.clone()).collect(); + assert_eq!(order, ["First", "Second", "Third"]); + let second = back.node(named(&back, "Second")).unwrap(); + assert_eq!(second.child_layout.sizing_h, Sizing::Fill); +} + +#[test] +fn grid_layout_keeps_tracks_and_cells() { + let mut doc = Document::new("Grid"); + let p = page(&doc); + let frame = add(&mut doc, p, NodeKind::Frame, "Grid", 0.0, 0.0, 300.0, 200.0); + let a = add(&mut doc, frame, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0); + add(&mut doc, frame, NodeKind::Rectangle, "B", 0.0, 0.0, 10.0, 10.0); + { + let n = doc.node_mut(a).unwrap(); + n.child_layout.grid_row = Some(1); + n.child_layout.grid_column = Some(0); + n.child_layout.grid_column_span = 2; + } + doc.node_mut(frame).unwrap().layout = Some(AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 3, + grid_rows: 2, + column_tracks: vec![GridTrack::fixed(50.0), GridTrack::fill(2.0), GridTrack::fixed(75.0)], + row_tracks: vec![GridTrack::hug(), GridTrack::fixed(40.0)], + gap: 5.0, + cross_gap: 7.0, + ..AutoLayout::default() + }); + let (back, e, _) = roundtrip(&doc); + let s = shape(&e, "Grid"); + assert_eq!(s["layoutGridColumns"], json!([{"type": "fixed", "value": 50}, {"type": "flex", "value": 2}, {"type": "fixed", "value": 75}])); + assert_eq!(s["layoutGridCells"].as_object().unwrap().len(), 5, "one spanning cell and four free ones"); + let al = back.node(named(&back, "Grid")).unwrap().layout.clone().unwrap(); + assert_eq!((al.mode, al.grid_columns, al.grid_rows, al.gap, al.cross_gap), (LayoutMode::Grid, 3, 2, 5.0, 7.0)); + assert_eq!(al.column_tracks[2], GridTrack::fixed(75.0)); + let a = &back.node(named(&back, "A")).unwrap().child_layout; + assert_eq!((a.grid_row, a.grid_column, a.grid_column_span), (Some(1), Some(0), 2)); + let b = &back.node(named(&back, "B")).unwrap().child_layout; + assert_eq!((b.grid_row, b.grid_column), (Some(0), Some(0))); +} + +#[test] +fn edited_text_is_rebuilt_with_runs_and_styles() { + let mut doc = Document::new("Text"); + let p = page(&doc); + let mut text = TextData::new("Hello bold\nSecond line"); + text.style.font_family = "Inter".into(); + text.style.font_size = 18.0; + text.style.line_height = LineHeight::Percent(150.0); + text.align_h = TextAlignH::Center; + text.auto_resize = TextAutoResize::Height; + let mut bold = text.style.clone(); + bold.font_style = "Bold Italic".into(); + bold.fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]); + text.runs.push(StyleRun { start: 6, end: 10, style: bold }); + let id = add(&mut doc, p, NodeKind::Text(text), "Copy", 0.0, 0.0, 200.0, 60.0); + doc.node_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex("#333333").unwrap())]; + + let (back, e, _) = roundtrip(&doc); + let s = shape(&e, "Copy"); + assert_eq!(s["growType"], "auto-height"); + let paragraphs = s["content"]["children"][0]["children"].as_array().unwrap(); + assert_eq!(paragraphs.len(), 2); + let leaves = paragraphs[0]["children"].as_array().unwrap(); + assert_eq!(leaves[1]["text"], "bold"); + assert_eq!(leaves[1]["fontWeight"], "700"); + assert_eq!(leaves[1]["fontStyle"], "italic"); + assert_eq!(leaves[1]["fontId"], "gfont-inter"); + assert_eq!(leaves[0]["textAlign"], "center"); + + let t = back.node(named(&back, "Copy")).unwrap().kind.text().unwrap().clone(); + assert_eq!(t.characters, "Hello bold\nSecond line"); + assert_eq!(t.style.font_size, 18.0); + assert_eq!(t.style.line_height, LineHeight::Percent(150.0)); + assert_eq!(t.align_h, TextAlignH::Center); + assert_eq!(t.auto_resize, TextAutoResize::Height); + let run = t.runs.iter().find(|r| r.start == 6).unwrap(); + assert_eq!((run.end, run.style.font_style.as_str()), (10, "Bold Italic")); + assert_eq!(run.style.fills.as_ref().and_then(|f| f[0].solid_color()), Color::from_hex("#ff0000")); +} + +fn penpot_text_archive() -> Vec { + let file = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let page = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + let text = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + let content = json!({"type": "root", "children": [{"type": "paragraph-set", "children": [{"type": "paragraph", "key": "k1", "fontId": "gfont-dm-sans", "fontFamily": "DM Sans", "fontSize": "20", "fontWeight": "600", "fontVariantId": "600", "children": [{"text": "Kept as written", "key": "k2", "fontId": "gfont-dm-sans", "fontFamily": "DM Sans", "fontSize": "20", "fontWeight": "600", "fontVariantId": "600", "fills": [{"fillColor": "#09071f", "fillOpacity": 1}]}]}]}]}); + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + let files = [ + ("manifest.json".to_owned(), json!({"type": "penpot/export-files", "version": 1, "files": [{"id": file, "name": "Kept"}]})), + (format!("files/{file}.json"), json!({"id": file, "name": "Kept", "version": 67, "features": ["components/v2"], "migrations": ["legacy-2"]})), + (format!("files/{file}/pages/{page}.json"), json!({"id": page, "name": "Page", "index": 0})), + (format!("files/{file}/pages/{page}/{ROOT_ID}.json"), json!({"id": ROOT_ID, "type": "frame", "shapes": [text]})), + ( + format!("files/{file}/pages/{page}/{text}.json"), + json!({"id": text, "name": "Title", "type": "text", "x": 10, "y": 20, "width": 200, "height": 24, "parentId": ROOT_ID, "frameId": ROOT_ID, "growType": "auto-width", "content": content, "positionData": [{"x": 10, "y": 44, "width": 150, "height": 24, "text": "Kept as written", "fills": []}]}), + ), + ]; + for (name, value) in files { + zip::ZipWriter::start_file(&mut z, name, opts).unwrap(); + std::io::Write::write_all(&mut z, value.to_string().as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +#[test] +fn unchanged_penpot_text_and_file_header_are_written_back() { + let (doc, _) = import(&penpot_text_archive()).unwrap(); + let (bytes, _) = export(&doc).unwrap(); + let e = entries(&bytes); + let s = shape(&e, "Title"); + assert_eq!(s["id"], "cccccccc-cccc-4ccc-8ccc-cccccccccccc"); + assert_eq!(s["content"]["children"][0]["children"][0]["key"], "k1"); + assert_eq!(s["positionData"][0]["text"], "Kept as written"); + let file = &e["files/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.json"]; + assert_eq!(file["migrations"], json!(["legacy-2"])); + assert_eq!(file["features"], json!(["components/v2"])); + assert!(e.contains_key("files/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/pages/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.json")); + + let mut edited = doc.clone(); + let id = named(&edited, "Title"); + if let NodeKind::Text(t) = &mut edited.node_mut(id).unwrap().kind { + t.characters = "Edited".into(); + } + let e = entries(&export(&edited).unwrap().0); + let s = shape(&e, "Title"); + assert_eq!(s["content"]["children"][0]["children"][0]["children"][0]["text"], "Edited"); + assert_eq!(s["content"]["children"][0]["children"][0]["children"][0]["fontId"], "gfont-dm-sans"); + assert!(s.get("positionData").is_none()); +} + +#[test] +fn components_instances_and_variants_round_trip() { + let mut doc = Document::new("Library"); + let p = page(&doc); + let main = add(&mut doc, p, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Button", 0.0, 0.0, 120.0, 40.0); + let label = add(&mut doc, main, NodeKind::Rectangle, "Fill", 0.0, 0.0, 120.0, 40.0); + doc.node_mut(label).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let copy = add(&mut doc, p, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Button copy", 0.0, 100.0, 120.0, 40.0); + let inner = add(&mut doc, copy, NodeKind::Rectangle, "Fill", 0.0, 0.0, 120.0, 40.0); + { + let n = doc.node_mut(inner).unwrap(); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.source = Some(label); + n.overrides.insert("fills".into()); + } + doc.node_mut(copy).unwrap().source = Some(main); + let set = add(&mut doc, p, NodeKind::ComponentSet { description: String::new() }, "Size", 300.0, 0.0, 200.0, 100.0); + for (name, y) in [("size=small", 0.0), ("size=large", 50.0)] { + add(&mut doc, set, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name, 0.0, y, 100.0, 40.0); + } + + let (back, e, _) = roundtrip(&doc); + let components: Vec<&Value> = e.iter().filter(|(k, _)| k.contains("/components/")).map(|(_, v)| v).collect(); + assert_eq!(components.len(), 3); + let variant = components.iter().find(|c| c["variantProperties"].is_array()).unwrap(); + assert_eq!(variant["variantId"], shape(&e, "Size")["id"]); + assert_eq!(shape(&e, "Size")["isVariantContainer"], true); + let copy_json = shape(&e, "Button copy"); + assert_eq!(copy_json["componentRoot"], true); + assert_eq!(copy_json["shapeRef"], shape(&e, "Button")["id"]); + let inner_json = e.values().find(|v| v["name"] == "Fill" && v["shapeRef"].is_string()).unwrap(); + assert_eq!(inner_json["touched"], json!(["fill-group"])); + + let copy = back.node(named(&back, "Button copy")).unwrap(); + assert!(matches!(copy.kind, NodeKind::Instance { component, .. } if component == named(&back, "Button"))); + assert!(copy.children.iter().all(|c| back.node(*c).unwrap().source.is_some())); + let set = back.node(named(&back, "Size")).unwrap(); + assert!(matches!(set.kind, NodeKind::ComponentSet { .. })); + let names: BTreeSet = set.children.iter().map(|c| back.node(*c).unwrap().name.clone()).collect(); + assert_eq!(names, BTreeSet::from(["size=small".to_owned(), "size=large".to_owned()])); +} + +fn variable(id: &str, name: &str, collection: &str, kind: VariableType, values: &[(&str, VariableValue)]) -> Variable { + Variable { + id: id.into(), + name: name.into(), + collection: collection.into(), + kind, + values: values.iter().map(|(m, v)| ((*m).to_owned(), v.clone())).collect(), + description: String::new(), + scopes: None, + hidden_from_publishing: false, + extends: None, + overrides: BTreeSet::new(), + code_syntax: BTreeMap::new(), + } +} + +#[test] +fn native_variables_become_token_sets_themes_and_applied_tokens() { + let mut doc = Document::new("Tokens"); + doc.variables.collections.push(Collection { + id: "c1".into(), + name: "Color".into(), + modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], + extends: None, + }); + doc.variables.collections.push(Collection { + id: "c2".into(), + name: "Size".into(), + modes: vec![Mode { id: "one".into(), name: "Default".into() }], + extends: None, + }); + doc.variables.variables.push(variable( + "v1", + "brand/primary", + "c1", + VariableType::Color, + &[("light", VariableValue::Color(Color::from_hex("#ff0000").unwrap())), ("dark", VariableValue::Color(Color::from_hex("#880000").unwrap()))], + )); + doc.variables.variables.push(variable("v2", "space/m", "c2", VariableType::Number, &[("one", VariableValue::Number(16.0))])); + doc.variables.variables.push(variable( + "v3", + "brand/alias", + "c1", + VariableType::Color, + &[("light", VariableValue::Alias("v1".into())), ("dark", VariableValue::Alias("v1".into()))], + )); + let p = page(&doc); + let id = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 16.0, 10.0); + doc.node_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]; + doc.node_mut(id).unwrap().bound.insert("fills/0/color".into(), "v1".into()); + doc.node_mut(id).unwrap().bound.insert("width".into(), "v2".into()); + + let (bytes, _) = export(&doc).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap(); + let tokens_path = z.file_names().find(|n| n.ends_with("tokens.json")).unwrap().to_owned(); + let mut text = String::new(); + z.by_name(&tokens_path).unwrap().read_to_string(&mut text).unwrap(); + let tokens: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(tokens["Color/Light"]["brand"]["primary"]["$value"], "#ff0000"); + assert_eq!(tokens["Color/Dark"]["brand"]["primary"]["$value"], "#880000"); + assert_eq!(tokens["Color/Light"]["brand"]["alias"]["$value"], "{brand.primary}"); + assert_eq!(tokens["Size"]["space"]["m"]["$value"], "16"); + assert_eq!(tokens["$themes"].as_array().unwrap().len(), 2); + assert_eq!(tokens["$metadata"]["activeThemes"], json!(["Color/Light"])); + let e = entries(&bytes); + assert_eq!(shape(&e, "Bound")["appliedTokens"], json!({"fill": "brand.primary", "width": "space.m"})); + + let (back, _) = import(&bytes).unwrap(); + assert!(back.variables.variables.iter().any(|v| v.name.ends_with("brand.primary"))); + let bound = back.node(named(&back, "Bound")).unwrap(); + assert!(bound.bound.contains_key("width")); +} + +#[test] +fn imported_penpot_tokens_are_written_back_verbatim() { + let mut doc = Document::new("Kept tokens"); + let p = page(&doc); + let source = r#"{"Base":{"radius":{"$value":"4","$type":"borderRadius","$description":""}},"$themes":[],"$metadata":{"tokenSetOrder":["Base"]}}"#; + doc.node_mut(p).unwrap().plugin_data.insert("penpot.tokens.f".into(), source.into()); + let fp = super::tokens_fingerprint(&doc).unwrap(); + doc.node_mut(p).unwrap().plugin_data.insert("penpot.fp.tokens".into(), fp); + let (bytes, warnings) = export(&doc).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap(); + let path = z.file_names().find(|n| n.ends_with("tokens.json")).unwrap().to_owned(); + let mut text = String::new(); + z.by_name(&path).unwrap().read_to_string(&mut text).unwrap(); + assert_eq!(text, source); + assert!(warnings.is_empty()); +} + +#[test] +fn prototype_interactions_and_flows_round_trip() { + let mut doc = Document::new("Proto"); + let p = page(&doc); + let home = add(&mut doc, p, NodeKind::Frame, "Home", 0.0, 0.0, 100.0, 100.0); + let next = add(&mut doc, p, NodeKind::Frame, "Next", 200.0, 0.0, 100.0, 100.0); + let menu = add(&mut doc, p, NodeKind::Frame, "Menu", 400.0, 0.0, 50.0, 50.0); + let button = add(&mut doc, home, NodeKind::Rectangle, "Go", 10.0, 10.0, 30.0, 30.0); + doc.node_mut(button).unwrap().interactions = vec![ + Interaction { + trigger: Trigger::OnClick, + actions: vec![Action::Navigate { dest: next, preserve_scroll: true }], + transition: Transition::SlideIn { direction: Direction::Right }, + duration_ms: 300, + easing: Easing::EaseOut, + delay_ms: 0, + }, + Interaction { + trigger: Trigger::MouseEnter, + actions: vec![Action::OpenOverlay { + dest: menu, + position: OverlayPosition::Center, + close_on_outside: true, + background: Some(Color::BLACK), + offset: None, + }], + transition: Transition::Dissolve, + duration_ms: 200, + easing: Easing::Linear, + delay_ms: 0, + }, + Interaction { + trigger: Trigger::AfterDelay { ms: 1500 }, + actions: vec![Action::Back], + transition: Transition::Instant, + duration_ms: 0, + easing: Easing::Linear, + delay_ms: 0, + }, + ]; + if let NodeKind::Page { flows, .. } = &mut doc.node_mut(p).unwrap().kind { + flows.push(Flow { name: "Main".into(), start: home, description: String::new() }); + } + let (back, e, _) = roundtrip(&doc); + let items = shape(&e, "Go")["interactions"].as_array().unwrap().clone(); + assert_eq!(items[0]["destination"], shape(&e, "Next")["id"]); + assert_eq!(items[0]["animation"]["animationType"], "slide"); + assert_eq!(items[1]["actionType"], "open-overlay"); + assert_eq!(items[2]["actionType"], "prev-screen"); + let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1; + assert_eq!(page_json["flows"].as_object().unwrap().values().next().unwrap()["startingFrame"], shape(&e, "Home")["id"]); + + let go = back.node(named(&back, "Go")).unwrap(); + assert_eq!(go.interactions.len(), 3); + assert!(matches!(go.interactions[0].actions[0], Action::Navigate { dest, preserve_scroll: true } if dest == named(&back, "Next"))); + assert_eq!(go.interactions[0].transition, Transition::SlideIn { direction: Direction::Right }); + assert_eq!((go.interactions[0].duration_ms, go.interactions[0].easing), (300, Easing::EaseOut)); + assert!(matches!(go.interactions[1].actions[0], Action::OpenOverlay { dest, close_on_outside: true, .. } if dest == named(&back, "Menu"))); + assert!(matches!(go.interactions[2].trigger, Trigger::AfterDelay { ms: 1500 })); + let NodeKind::Page { flows, .. } = &back.node(back.pages[0]).unwrap().kind else { panic!() }; + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].start, named(&back, "Home")); +} + +#[test] +fn image_fills_write_media_and_storage_objects() { + let mut doc = Document::new("Images"); + let mut png = Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let (hash, _, _) = crate::add_image(&mut doc, png.into_inner()).unwrap(); + let p = page(&doc); + let id = add(&mut doc, p, NodeKind::Rectangle, "Picture", 0.0, 0.0, 30.0, 20.0); + doc.node_mut(id).unwrap().fills = vec![Paint::image(hash.clone(), ImageScaleMode::Fill)]; + let (back, e, bytes) = roundtrip(&doc); + let fill = &shape(&e, "Picture")["fills"][0]["fillImage"]; + assert_eq!((fill["width"].as_u64(), fill["height"].as_u64(), fill["mtype"].as_str()), (Some(3), Some(2), Some("image/png"))); + let media = e.iter().find(|(k, _)| k.contains("/media/")).unwrap().1; + assert_eq!(media["id"], fill["id"]); + let object = &e[&format!("objects/{}.json", media["mediaId"].as_str().unwrap())]; + let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap(); + let blob = z.by_name(&format!("objects/{}.png", media["mediaId"].as_str().unwrap())).unwrap().size(); + assert_eq!(object["size"].as_u64(), Some(blob)); + assert_eq!(object["bucket"], "file-media-object"); + let n = back.node(named(&back, "Picture")).unwrap(); + assert!(matches!(&n.fills[0].kind, PaintKind::Image(img) if img.image == hash)); +} + +#[test] +fn duplicated_penpot_ids_are_made_unique_per_page() { + let mut doc = Document::new("Dupes"); + let p = page(&doc); + for name in ["One", "Two"] { + let id = add(&mut doc, p, NodeKind::Rectangle, name, 0.0, 0.0, 10.0, 10.0); + doc.node_mut(id).unwrap().plugin_data.insert("penpot.id".into(), "dddddddd-dddd-4ddd-8ddd-dddddddddddd".into()); + } + let (back, e, _) = roundtrip(&doc); + assert_eq!(shape(&e, "One")["id"], "dddddddd-dddd-4ddd-8ddd-dddddddddddd"); + assert_ne!(shape(&e, "Two")["id"], shape(&e, "One")["id"]); + assert_eq!(back.nodes().filter(|n| matches!(n.kind, NodeKind::Rectangle)).count(), 2); +} + +#[test] +fn unsupported_paints_and_effects_are_reported() { + let mut doc = Document::new("Lossy"); + let p = page(&doc); + let id = add(&mut doc, p, NodeKind::Rectangle, "Lossy", 0.0, 0.0, 10.0, 10.0); + let mut hidden = Paint::solid(Color::BLACK); + hidden.visible = false; + doc.node_mut(id).unwrap().fills = vec![hidden]; + let (_, warnings) = export(&doc).unwrap(); + assert!(warnings.iter().any(|w| w.contains("Hidden fills"))); +} + +#[test] +fn a_coloured_run_does_not_recolour_the_rest_of_the_text() { + let mut text = TextData::new("plain red"); + text.style.fills = Some(vec![Paint::solid(Color::BLACK)]); + let mut red = text.style.clone(); + red.fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]); + text.runs.push(StyleRun { start: 6, end: 9, style: red }); + assert_eq!(super::uniform_text_fills(&text), None); + text.runs[0].start = 0; + assert!(super::uniform_text_fills(&text).is_some()); +} + +#[test] +fn display_grids_and_guides_round_trip() { + let mut doc = Document::new("Grids"); + let p = page(&doc); + if let NodeKind::Page { guides, .. } = &mut doc.node_mut(p).unwrap().kind { + guides.push(figcraft_doc::Guide { vertical: true, offset: 64.0 }); + } + let frame = add(&mut doc, p, NodeKind::Frame, "Board", 100.0, 50.0, 400.0, 300.0); + { + let n = doc.node_mut(frame).unwrap(); + n.grids = vec![ + figcraft_doc::LayoutGrid { count: 12, gutter: 16.0, offset: 24.0, ..figcraft_doc::LayoutGrid::columns() }, + figcraft_doc::LayoutGrid { size: 8.0, ..figcraft_doc::LayoutGrid::default() }, + ]; + n.guides = vec![figcraft_doc::Guide { vertical: false, offset: 10.0 }]; + } + let (back, e, _) = roundtrip(&doc); + let grids = &shape(&e, "Board")["grids"]; + assert_eq!(grids[0]["type"], "column"); + assert_eq!(grids[0]["params"]["size"], 12); + assert_eq!(grids[1]["params"]["size"], 8); + let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1; + let guides: Vec<&Value> = page_json["guides"].as_object().unwrap().values().collect(); + assert!(guides.iter().any(|g| g["axis"] == "x" && g["position"] == 64 && g.get("frameId").is_none())); + assert!(guides.iter().any(|g| g["axis"] == "y" && g["position"] == 60 && g["frameId"] == shape(&e, "Board")["id"])); + let n = back.node(named(&back, "Board")).unwrap(); + assert_eq!((n.grids[0].count, n.grids[0].gutter, n.grids[0].offset), (12, 16.0, 24.0)); + assert_eq!(n.grids[1].size, 8.0); + let NodeKind::Page { guides, .. } = &back.node(back.pages[0]).unwrap().kind else { panic!() }; + assert!(guides.iter().any(|g| g.vertical && g.offset == 64.0)); +} + +#[test] +fn raw_svg_layers_go_back_as_svg_raw_without_their_expansion() { + let mut doc = Document::new("Svg"); + let p = page(&doc); + let raw = add(&mut doc, p, NodeKind::Frame, "Logo", 0.0, 0.0, 24.0, 24.0); + add(&mut doc, raw, NodeKind::Frame, "Expanded", 0.0, 0.0, 24.0, 24.0); + { + let n = doc.node_mut(raw).unwrap(); + n.plugin_data.insert("penpot.id".into(), "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee".into()); + n.plugin_data.insert("penpot.svgRawStatus".into(), "expanded".into()); + n.plugin_data.insert("penpot.source.content".into(), r#"{"tag":"svg","attrs":{"viewBox":"0 0 24 24"},"content":[]}"#.into()); + } + let (bytes, _) = export(&doc).unwrap(); + let e = entries(&bytes); + let s = shape(&e, "Logo"); + assert_eq!(s["type"], "svg-raw"); + assert_eq!(s["content"]["tag"], "svg"); + assert_eq!(s["shapes"], json!([])); + assert!(!e.values().any(|v| v["name"] == "Expanded")); +} + +#[test] +fn grid_children_beyond_the_declared_rows_get_implicit_rows() { + let mut doc = Document::new("Overflow"); + let p = page(&doc); + let frame = add(&mut doc, p, NodeKind::Frame, "Grid", 0.0, 0.0, 200.0, 200.0); + for i in 0..5 { + add(&mut doc, frame, NodeKind::Rectangle, &format!("Item {i}"), 0.0, 0.0, 10.0, 10.0); + } + doc.node_mut(frame).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 1, ..AutoLayout::default() }); + let (bytes, warnings) = export(&doc).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let e = entries(&bytes); + let s = shape(&e, "Grid"); + assert_eq!(s["layoutGridRows"].as_array().unwrap().len(), 3); + let filled = s["layoutGridCells"].as_object().unwrap().values().filter(|c| !c["shapes"].as_array().unwrap().is_empty()).count(); + assert_eq!(filled, 5); +} + +fn penpot_archive(shapes: &[Value]) -> Vec { + let file = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let page = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + let top: Vec<&Value> = shapes.iter().filter(|s| s["parentId"] == ROOT_ID).map(|s| &s["id"]).collect(); + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + let mut files = vec![ + ("manifest.json".to_owned(), json!({"type": "penpot/export-files", "version": 1, "files": [{"id": file, "name": "Kept"}]})), + (format!("files/{file}/pages/{page}.json"), json!({"id": page, "name": "Page", "index": 0})), + (format!("files/{file}/pages/{page}/{ROOT_ID}.json"), json!({"id": ROOT_ID, "type": "frame", "shapes": top})), + ]; + for s in shapes { + files.push((format!("files/{file}/pages/{page}/{}.json", s["id"].as_str().unwrap()), s.clone())); + } + for (name, value) in files { + zip::ZipWriter::start_file(&mut z, name, opts).unwrap(); + std::io::Write::write_all(&mut z, value.to_string().as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +#[test] +fn penpot_only_layer_data_survives_unchanged_layers() { + let board = "11111111-1111-4111-8111-111111111111"; + let ring = "22222222-2222-4222-8222-222222222222"; + let label = "33333333-3333-4333-8333-333333333333"; + let strokes = json!([ + {"strokeColor": "#000000", "strokeOpacity": 1, "strokeWidth": 4, "strokeAlignment": "outer", "strokeStyle": "solid"}, + {"strokeColor": "#00ffff", "strokeOpacity": 1, "strokeWidth": 1, "strokeAlignment": "inner", "strokeStyle": "solid"} + ]); + let position: Vec = + (0..400).map(|i| json!({"x": 0, "y": i, "width": 100, "height": 20, "text": "a line of positioned text", "fills": []})).collect(); + let content = json!({"type": "root", "children": [{"type": "paragraph-set", "children": [{"type": "paragraph", "children": [{"text": "Label", "fontSize": "14"}]}, {"type": "paragraph", "children": [{"text": ""}]}]}]}); + let bytes = penpot_archive(&[ + json!({"id": board, "name": "Board", "type": "frame", "x": 0, "y": 0, "width": 200, "height": 100, "parentId": ROOT_ID, "frameId": ROOT_ID, "shapes": [ring, label], "layout": "flex", "layoutFlexDir": "row", "exports": [{"type": "png", "suffix": "", "scale": 2}]}), + json!({"id": ring, "name": "Ring", "type": "circle", "x": 10, "y": 10, "width": 40, "height": 40, "parentId": board, "frameId": board, "strokes": strokes, "layoutItemZIndex": 2}), + json!({"id": label, "name": "Label", "type": "text", "x": 60, "y": 10, "width": 100, "height": 40, "parentId": board, "frameId": board, "content": content, "positionData": position, "hideInViewer": true}), + ]); + let (doc, _) = import(&bytes).unwrap(); + let e = entries(&export(&doc).unwrap().0); + assert_eq!(shape(&e, "Ring")["strokes"], strokes); + assert_eq!(shape(&e, "Ring")["layoutItemZIndex"], 2); + assert_eq!(shape(&e, "Board")["exports"][0]["scale"], 2); + assert_eq!(shape(&e, "Label")["hideInViewer"], true); + assert_eq!(shape(&e, "Label")["positionData"].as_array().unwrap().len(), 400); + assert_eq!(shape(&e, "Label")["fills"], json!([])); + + let mut edited = doc.clone(); + let id = named(&edited, "Ring"); + edited.node_mut(id).unwrap().stroke.weight = 6.0; + let e = entries(&export(&edited).unwrap().0); + let regenerated = shape(&e, "Ring")["strokes"].as_array().unwrap().clone(); + assert!(regenerated.iter().all(|s| s["strokeWidth"] == 6)); +} + +#[test] +fn a_penpot_header_without_migrations_lets_penpot_run_them() { + let mut doc = Document::new("Old"); + let p = page(&doc); + doc.node_mut(p).unwrap().plugin_data.insert("penpot.source.file".into(), r#"{"version":58,"features":["components/v2"]}"#.into()); + let e = entries(&export(&doc).unwrap().0); + let file = e.iter().find(|(k, _)| k.matches('/').count() == 1).unwrap().1; + assert_eq!(file["version"], 58); + assert!(file.get("migrations").is_none()); +} + +#[test] +fn legacy_image_shapes_stretch_over_their_fills_like_penpot() { + let file = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let page = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + let photo_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + let media = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + let object = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let mut png = Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 10, 10, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + let members: Vec<(String, Vec)> = vec![ + ("manifest.json".into(), json!({"type": "penpot/export-files", "version": 1, "files": [{"id": file, "name": "Legacy"}]}).to_string().into_bytes()), + (format!("files/{file}/pages/{page}.json"), json!({"id": page, "name": "Page", "index": 0}).to_string().into_bytes()), + (format!("files/{file}/pages/{page}/{ROOT_ID}.json"), json!({"id": ROOT_ID, "type": "frame", "shapes": [photo_id]}).to_string().into_bytes()), + ( + format!("files/{file}/pages/{page}/{photo_id}.json"), + json!({"id": photo_id, "type": "image", "name": "Photo", "width": 100, "height": 100, "metadata": {"id": media, "width": 4, "height": 2, "mtype": "image/png"}, "fills": [{"fillColor": "#00ff00", "fillOpacity": 1}]}) + .to_string() + .into_bytes(), + ), + (format!("files/{file}/media/{media}.json"), json!({"id": media, "mtype": "image/png", "mediaId": object}).to_string().into_bytes()), + (format!("objects/{object}.png"), png.into_inner()), + ]; + for (name, data) in members { + zip::ZipWriter::start_file(&mut z, name, opts).unwrap(); + std::io::Write::write_all(&mut z, &data).unwrap(); + } + let (doc, _) = import(&z.finish().unwrap().into_inner()).unwrap(); + let photo = doc.node(named(&doc, "Photo")).unwrap(); + assert_eq!(photo.fills.len(), 2); + assert!(matches!(&photo.fills[1].kind, PaintKind::Image(img) if img.scale_mode == ImageScaleMode::Crop)); + let e = entries(&export(&doc).unwrap().0); + assert_eq!(shape(&e, "Photo")["fills"][0]["fillImage"]["keepAspectRatio"], false); + assert_eq!(shape(&e, "Photo")["fills"][1]["fillColor"], "#00ff00"); +} + +#[test] +fn page_guides_reloaded_from_a_figcraft_file_are_exported() { + let mut doc = Document::new("Reloaded"); + let p = page(&doc); + doc.node_mut(p).unwrap().guides = vec![figcraft_doc::Guide { vertical: false, offset: 410.0 }]; + let e = entries(&export(&doc).unwrap().0); + let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1; + assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410); +} diff --git a/crates/format/src/penpot/prototype.rs b/crates/format/src/penpot/prototype.rs new file mode 100644 index 000000000..bec13484c --- /dev/null +++ b/crates/format/src/penpot/prototype.rs @@ -0,0 +1,346 @@ +//! Penpot prototype interactions and flows, mapped to FigCraft's native prototype model. +//! +//! Penpot stores an interaction on the shape that fires it: an event type (`click`, +//! `mouse-press`, `mouse-over`, `mouse-enter`, `mouse-leave`, `after-delay`), an action +//! type (`navigate`, `open-overlay`, `toggle-overlay`, `close-overlay`, `prev-screen`, +//! `open-url`), the destination shape and an optional animation. FigCraft models the same +//! idea as an [`Interaction`] (a trigger plus a list of actions) and a [`Flow`] (a named +//! starting frame on a page). +//! +//! A destination is a Penpot shape id, but a FigCraft action points at a node id. The +//! importer therefore keeps the Penpot source of every interaction in the layer's metadata +//! and reads it back once every page is in the document, when the Penpot ids can be +//! resolved to node ids (see [`resolve_destinations`]). + +use std::collections::BTreeMap; + +use figcraft_doc::color::Color; +use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger}; +use figcraft_doc::{Document, NodeId}; +use serde_json::Value; + +use super::warn; + +/// Longest animation Penpot may declare, in milliseconds. +const MAX_DURATION_MS: u32 = 600_000; +/// Longest "after delay" wait, in milliseconds. +const MAX_DELAY_MS: u32 = 600_000; +/// Most interactions read from one shape. +const MAX_INTERACTIONS: usize = 64; +/// Most flows a page may declare (Penpot allows two). +const MAX_FLOWS: usize = 8; +/// Longest stored interaction source for one shape, in bytes. +const MAX_INTERACTION_SOURCE: usize = 64 * 1024; + +/// Penpot's `event-type` names, mapped to FigCraft's triggers. +fn penpot_trigger(event: Option<&str>, delay_ms: Option) -> Option { + match event? { + "click" => Some(Trigger::OnClick), + // "mouse-press" fires while the pointer is held: FigCraft's "while pressing". + "mouse-press" => Some(Trigger::OnPress), + // "mouse-over" fires while the pointer is over the shape. + "mouse-over" => Some(Trigger::OnHover), + "mouse-enter" => Some(Trigger::MouseEnter), + "mouse-leave" => Some(Trigger::MouseLeave), + // Penpot's own default wait for a delayed trigger. + "after-delay" => Some(Trigger::AfterDelay { ms: delay_ms.unwrap_or(600).min(MAX_DELAY_MS) }), + _ => None, + } +} + +/// Penpot's `overlay-pos-type` names, mapped to FigCraft's overlay positions. +fn penpot_overlay_position(position: Option<&str>) -> Option { + Some(match position? { + "manual" => OverlayPosition::Manual, + "center" => OverlayPosition::Center, + "top-left" => OverlayPosition::TopLeft, + "top-right" => OverlayPosition::TopRight, + "top-center" => OverlayPosition::TopCenter, + "bottom-left" => OverlayPosition::BottomLeft, + "bottom-right" => OverlayPosition::BottomRight, + "bottom-center" => OverlayPosition::BottomCenter, + _ => return None, + }) +} + +/// What a Penpot action does, without the destination it points at. +enum PenpotActionKind { + Navigate, + OpenOverlay, + ToggleOverlay, + CloseOverlay, + Back, + OpenUrl, +} + +/// Penpot's `action-type` names. +fn penpot_action_kind(action: Option<&str>) -> Option { + Some(match action? { + "navigate" => PenpotActionKind::Navigate, + "open-overlay" => PenpotActionKind::OpenOverlay, + "toggle-overlay" => PenpotActionKind::ToggleOverlay, + "close-overlay" => PenpotActionKind::CloseOverlay, + "prev-screen" => PenpotActionKind::Back, + "open-url" => PenpotActionKind::OpenUrl, + _ => return None, + }) +} + +/// Whether the action opens an overlay (Penpot plays its animation differently). +fn is_overlay(kind: &PenpotActionKind) -> bool { + matches!(kind, PenpotActionKind::OpenOverlay | PenpotActionKind::ToggleOverlay) +} + +/// Penpot's animation kinds, mapped to FigCraft's transitions. +/// +/// Penpot animates a navigation with `dissolve`, `slide` or `push`; FigCraft's `SlideIn` +/// and `SlideOut` are the closest to Penpot's `slide`, whose `way` says whether the new +/// screen comes in or the old one goes out. +fn penpot_transition(animation: Option<&Value>, overlay: bool) -> (Transition, u32, Easing) { + let Some(animation) = animation else { return (Transition::Instant, 0, Easing::Linear) }; + let kind = animation.get("animationType").and_then(Value::as_str); + let duration = animation + .get("duration") + .and_then(Value::as_f64) + .filter(|d| d.is_finite() && *d >= 0.0) + .map(|d| d.min(f64::from(MAX_DURATION_MS)) as u32) + .unwrap_or(0); + let easing = match animation.get("easing").and_then(Value::as_str) { + Some("linear") => Easing::Linear, + Some("ease") => Easing::EaseInOut, + Some("ease-in") => Easing::EaseIn, + Some("ease-out") => Easing::EaseOut, + Some("ease-in-out") => Easing::EaseInOut, + _ => Easing::Linear, + }; + // Penpot's direction names the way the screen moves, as FigCraft's does. + let direction = match animation.get("direction").and_then(Value::as_str) { + Some("right") => Direction::Right, + Some("up") => Direction::Top, + Some("down") => Direction::Bottom, + _ => Direction::Left, + }; + // An overlay's animation is the overlay coming in: its "way" does not apply, and a + // push is not an overlay animation at all. + let way = if overlay { Some("in") } else { animation.get("way").and_then(Value::as_str) }; + let transition = match (kind, way) { + (Some("dissolve"), _) => Transition::Dissolve, + (Some("slide"), Some("out")) => Transition::SlideOut { direction }, + (Some("slide"), _) => Transition::SlideIn { direction }, + (Some("push"), _) if !overlay => Transition::Push { direction }, + _ => Transition::Instant, + }; + (transition, duration, easing) +} + +/// One Penpot interaction, read from a shape. +pub(super) struct PenpotInteraction { + /// The trigger that fires it. + pub trigger: Trigger, + /// The action it runs. Its destination is a placeholder until the ids are resolved. + pub action: Action, + /// The Penpot shape id the action points at, if any. + pub destination: Option, + /// The transition it plays. + pub transition: Transition, + /// How long the transition lasts, in milliseconds. + pub duration_ms: u32, + /// The curve the transition follows. + pub easing: Easing, +} + +/// The action a Penpot interaction runs, with its destination left for the resolver. +fn penpot_action(item: &Value, kind: &PenpotActionKind) -> Option<(Action, Option)> { + let destination = item.get("destination").and_then(Value::as_str).map(str::to_owned); + let action = match kind { + PenpotActionKind::Navigate => { + Action::Navigate { dest: NodeId(0), preserve_scroll: item.get("preserveScroll").and_then(Value::as_bool).unwrap_or(false) } + } + PenpotActionKind::ToggleOverlay => return None, + PenpotActionKind::OpenOverlay => { + let position = penpot_overlay_position(item.get("overlayPosType").and_then(Value::as_str))?; + // A manual overlay sits at an explicit offset from the layer it was opened + // from. FigCraft measures that offset from the top-left of the opening layer. + let offset = match (position, item.get("overlayPosition")) { + (OverlayPosition::Manual, Some(p)) => { + let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0); + (x.is_finite() && y.is_finite()).then_some([x.clamp(-100_000.0, 100_000.0), y.clamp(-100_000.0, 100_000.0)]) + } + _ => None, + }; + // Penpot leaves "close when clicking outside" off unless it is asked for. + let close_on_outside = item.get("closeClickOutside").and_then(Value::as_bool).unwrap_or(false); + // Penpot draws a translucent black sheet behind an overlay that asks for one. + let background = item.get("backgroundOverlay").and_then(Value::as_bool).unwrap_or(false).then(|| Color::new(0.0, 0.0, 0.0, 0.2)); + Action::OpenOverlay { dest: NodeId(0), position, close_on_outside, background, offset } + } + PenpotActionKind::CloseOverlay => Action::CloseOverlay, + // Penpot's "previous screen" is the player's back action. + PenpotActionKind::Back => Action::Back, + PenpotActionKind::OpenUrl => { + let url = item.get("url").and_then(Value::as_str)?.trim(); + if url.is_empty() || url.len() > 2048 { + return None; + } + Action::OpenUrl { url: url.chars().take(2048).collect() } + } + }; + let needs_destination = matches!(kind, PenpotActionKind::Navigate | PenpotActionKind::OpenOverlay | PenpotActionKind::ToggleOverlay); + Some((action, if needs_destination { destination } else { None })) +} + +/// Map one shape's `interactions` array. +/// +/// Returns the interactions that could be mapped, and a note for each one that could not. +pub(super) fn shape_interactions(items: &[Value], warnings: &mut Vec) -> Vec { + let mut out = Vec::new(); + for item in items.iter().take(MAX_INTERACTIONS) { + let event = item.get("eventType").and_then(Value::as_str); + let Some(trigger) = penpot_trigger(event, item.get("delay").and_then(Value::as_u64).map(|d| d.min(u64::from(MAX_DELAY_MS)) as u32)) else { + warnings.push(format!("Penpot interaction with event type '{}' has no FigCraft equivalent and was skipped", event.unwrap_or("unknown"))); + continue; + }; + let Some(kind) = penpot_action_kind(item.get("actionType").and_then(Value::as_str)) else { + warnings.push(format!( + "Penpot interaction with action type '{}' has no FigCraft equivalent and was skipped", + item.get("actionType").and_then(Value::as_str).unwrap_or("unknown") + )); + continue; + }; + let Some((action, destination)) = penpot_action(item, &kind) else { + warnings.push(format!( + "Penpot interaction with action type '{}' has no FigCraft equivalent and was skipped", + item.get("actionType").and_then(Value::as_str).unwrap_or("unknown") + )); + continue; + }; + if destination.is_none() && matches!(kind, PenpotActionKind::Navigate | PenpotActionKind::OpenOverlay | PenpotActionKind::ToggleOverlay) { + warnings.push("A Penpot interaction names no destination; it was skipped".into()); + continue; + } + let (transition, duration_ms, easing) = penpot_transition(item.get("animation"), is_overlay(&kind)); + out.push(PenpotInteraction { trigger, action, destination, transition, duration_ms, easing }); + } + out +} + +/// Keep a shape's interaction source in its metadata, so the destinations can be resolved +/// once every page is in the document. +pub(super) fn keep_interaction_source(node: &mut figcraft_doc::Node, source: &Value, warnings: &mut Vec) { + let Some(items) = source.get("interactions").and_then(Value::as_array) else { return }; + if items.is_empty() { + return; + } + match serde_json::to_string(items) { + Ok(encoded) if encoded.len() <= MAX_INTERACTION_SOURCE => { + node.plugin_data.insert("penpot.interactions".into(), encoded); + } + _ => warn(warnings, "A Penpot layer's interaction source is too large to keep; its prototype links were skipped".into()), + } +} + +/// The interactions a shape's kept source holds. +fn stored_interactions(node: &figcraft_doc::Node) -> Option> { + let raw = node.plugin_data.get("penpot.interactions")?; + serde_json::from_str::(raw).ok()?.as_array().cloned() +} + +/// Rewrite the placeholder destinations of the imported interactions once every page is in +/// the document. +/// +/// The importer keeps each shape's Penpot interactions in its metadata. Once the document +/// is complete the Penpot ids are replaced by the FigCraft node ids they name, so the +/// prototype player can follow them. A destination that names no layer is dropped, and an +/// interaction left with no action is removed. +pub(super) fn resolve_destinations(doc: &mut Document, by_penpot_id: &BTreeMap, warnings: &mut Vec) { + let mut mapped = 0usize; + let mut missing = 0usize; + let mut updates: Vec<(NodeId, Vec)> = Vec::new(); + for node in doc.nodes() { + let Some(items) = stored_interactions(node) else { continue }; + let mut out = Vec::new(); + let mut notes = Vec::new(); + let interactions = shape_interactions(&items, &mut notes); + for note in notes { + warn(warnings, note); + } + for interaction in interactions { + let Some(destination) = &interaction.destination else { + // An action with no destination (a link, a back) needs no resolution. + out.push(Interaction { + trigger: interaction.trigger, + actions: vec![interaction.action], + transition: interaction.transition, + duration_ms: interaction.duration_ms, + easing: interaction.easing, + delay_ms: 0, + }); + continue; + }; + let Some(&id) = by_penpot_id.get(destination) else { + missing += 1; + continue; + }; + out.push(Interaction { + trigger: interaction.trigger, + actions: vec![replace_dest(interaction.action, id)], + transition: interaction.transition, + duration_ms: interaction.duration_ms, + easing: interaction.easing, + delay_ms: 0, + }); + mapped += 1; + } + if !out.is_empty() { + updates.push((node.id, out)); + } + } + for (id, interactions) in updates { + if let Some(node) = doc.node_mut(id) { + node.interactions = interactions; + } + } + if mapped > 0 { + warnings.push(format!("Imported {mapped} Penpot prototype connection(s) as working FigCraft interactions")); + } + if missing > 0 { + warnings.push(format!("{missing} Penpot prototype destination(s) named no layer in the file; those connections were skipped")); + } +} + +/// The same action with another destination. +fn replace_dest(action: Action, dest: NodeId) -> Action { + match action { + Action::Navigate { preserve_scroll, .. } => Action::Navigate { dest, preserve_scroll }, + Action::ScrollTo { offset, .. } => Action::ScrollTo { dest, offset }, + Action::OpenOverlay { position, close_on_outside, background, offset, .. } => { + Action::OpenOverlay { dest, position, close_on_outside, background, offset } + } + Action::SwapOverlay { .. } => Action::SwapOverlay { dest }, + Action::ChangeTo { .. } => Action::ChangeTo { dest }, + other => other, + } +} + +/// Read a page's Penpot `flows` and turn them into FigCraft flows. +/// +/// A Penpot flow names the frame the prototype starts at. FigCraft's flow needs a node id, +/// so the Penpot frame id is resolved through `by_penpot_id`. +pub(super) fn page_flows(doc: &Document, flows: &Value, by_penpot_id: &BTreeMap, warnings: &mut Vec) -> Vec { + let mut out = Vec::new(); + let Some(flows) = flows.as_object() else { return out }; + for flow in flows.values().take(MAX_FLOWS) { + let Some(start) = flow.get("startingFrame").and_then(Value::as_str) else { continue }; + let Some(&frame) = by_penpot_id.get(start) else { + // A flow whose starting frame is not in the file cannot start anything. + warnings.push("A Penpot flow names a starting frame that is not in the file; it was skipped".into()); + continue; + }; + // The player shows the top-level frame a starting frame sits in. + let start = doc.top_level_frame(frame).unwrap_or(frame); + let name = flow.get("name").and_then(Value::as_str).map(str::to_owned).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Flow".to_owned()); + out.push(Flow { name: name.chars().take(256).collect(), start, description: String::new() }); + } + out +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index f4b30e10e..dfb26c8f2 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -21,7 +21,7 @@ pub mod width; mod width_tests; use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork, WindingRule}; -use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2}; +use figcraft_geom::kurbo::{self, BezPath, Cap, Join, ParamCurveArclen, PathEl, PathSeg, Point, Shape, Vec2}; #[derive(Debug, PartialEq)] pub enum PathOpsError { @@ -293,7 +293,17 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath .with_start_cap(kcap(props.cap_start)) .with_end_cap(kcap(props.cap_end)); if let Some(pat) = width::dash_pattern(props) { - style = style.with_dashes(0.0, pat); + let dots = [props.cap_start, props.cap_end].into_iter().any(|c| matches!(kcap(c), Cap::Round)); + let pat: Vec = pat.into_iter().map(|d| if dots && d == 0.0 { 1e-3 } else { d }).collect(); + // Each subpath restarts the pattern. Budget both total length and per-subpath starts, + // otherwise tiny round-capped dots can expand into hundreds of thousands of contours. + let starts = path.elements().iter().filter(|el| matches!(el, PathEl::MoveTo(_))).count(); + let length: f64 = path.segments().map(|seg| seg.arclen(0.05)).sum(); + let period: f64 = pat.iter().sum(); + let estimated = (length / period + starts as f64) * pat.len() as f64; + if period.is_finite() && period > 0.0 && estimated.is_finite() && estimated < width::MAX_DASHES as f64 { + style = style.with_dashes(0.0, pat); + } } kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05) } @@ -742,4 +752,43 @@ mod tests { let s = stroke_outline_of(&ring, &inside, true, Some(&nz)); assert!(s.winding(material) != 0 && s.winding(hole) != 0); } + + #[test] + fn dense_round_dashes_fall_back_before_expansion() { + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((10_000.0, 0.0)); + let dangerous = StrokeProps { + weight: 2.0, + dashes: vec![0.0, 0.02], + cap_start: StrokeCap::Round, + cap_end: StrokeCap::Round, + align: StrokeAlign::Center, + ..Default::default() + }; + let outlined = stroke_center(&line, 2.0, &dangerous); + let solid = stroke_center(&line, 2.0, &StrokeProps { dashes: vec![], ..dangerous }); + assert!((outlined.area().abs() - solid.area().abs()).abs() < 0.01); + assert!(outlined.elements().len() < 1000, "fallback must not emit hundreds of thousands of dots"); + } + + #[test] + fn zero_length_round_dashes_draw_dots() { + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((100.0, 0.0)); + let round = StrokeProps { + weight: 2.0, + dashes: vec![0.0, 9.0], + cap_start: StrokeCap::Round, + cap_end: StrokeCap::Round, + align: StrokeAlign::Center, + ..Default::default() + }; + let dots = stroke_center(&line, 2.0, &round); + let area = dots.area().abs(); + assert!((area - 12.0 * std::f64::consts::PI).abs() < 1.5, "{area}"); + let butt = StrokeProps { cap_start: StrokeCap::None, cap_end: StrokeCap::None, ..round }; + assert!(stroke_center(&line, 2.0, &butt).area().abs() < 1e-6); + } } diff --git a/crates/pathops/src/width.rs b/crates/pathops/src/width.rs index 03240284b..fb4c4f741 100644 --- a/crates/pathops/src/width.rs +++ b/crates/pathops/src/width.rs @@ -18,7 +18,7 @@ pub use figcraft_geom::measure::{Polyline, polylines}; /// straight runs. const SAMPLES_PER_SUBPATH: f64 = 128.0; /// The most dashes a dashed variable width stroke makes. -const MAX_DASHES: usize = 20_000; +pub(crate) const MAX_DASHES: usize = 20_000; /// The nearest point of the stroke path to `p`: (subpath index, t by length, distance). pub fn nearest(lines: &[Polyline], p: Point) -> Option<(usize, f64, f64)> { diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 0eec98088..63253c494 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1970,6 +1970,7 @@ status = "draft — needs native review" "file.newFromTemplate" = "Nuevo desde plantilla" "file.open" = "Abrir" "file.openRecent" = "Abrir reciente" +"file.fontReport" = "Inspeccionar fuentes del documento" "file.placeImage" = "Colocar imagen" "file.recover" = "Recuperar archivo" "file.rename" = "Cambiar nombre del archivo" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da40bf0c1..900f18c33 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1251,7 +1251,7 @@ impl FigApp { let exts: &[&str] = if place { &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"] } else { - &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] + &["penpot", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; self.pick_file(title, exts, true, |app, file| { @@ -1885,17 +1885,17 @@ impl FigApp { } /// The file types File ▸ Open and a drop on the home screen open. -pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; +pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "penpot", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; /// Whether `path` names a file type that opens as a document (the extension, in any case). pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(OsStr::to_str).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG: +/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.penpot`, file JSON) rather than an image or SVG: /// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. pub(crate) fn is_document(path: &Path) -> bool { - path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "penpot", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Reads the contents of dropped file `name` that has no file on disk to read by path. diff --git a/docs/PENPOT_IMPORT.md b/docs/PENPOT_IMPORT.md new file mode 100644 index 000000000..ecdda4dc8 --- /dev/null +++ b/docs/PENPOT_IMPORT.md @@ -0,0 +1,107 @@ +# Penpot import and export + +FigCraft reads and writes Penpot 2.x ZIP archives (`penpot/export-files`, version 1) +in Rust. Import and export run offline on desktop, CLI, MCP and WASM. + +## Open and save + +- **File → Open…** opens a `.penpot` as a separate unsaved document. Dropping it + onto the canvas does the same. The original archive is not overwritten. +- **File → Import from file…** adds its pages to the current document. +- **Save / Save as…** writes native `.figcraft` files. +- **Export** with format `penpot` writes an archive for a Penpot team. + +Engine commands use the same importer and exporter: + +```json +{"command":"file.open","params":{"path":"example.penpot"}} +{"command":"file.import","params":{"path":"example.penpot"}} +{"command":"file.export","params":{"format":"penpot","path":"out.penpot"}} +``` + +Without a path, `file.export` returns base64 `data`. Unsupported conversions are +reported in `warnings`; the CLI prints them as notes. + +```sh +figcraft-cli info example.penpot --json +figcraft-cli convert example.penpot editable.figcraft +figcraft-cli export editable.figcraft -o out.penpot --format penpot +``` + +## Supported content + +| Area | Support | +|---|---| +| Structure | Pages, boards, groups, masks, nested frames, editable shapes and vectors | +| Appearance | Solid, gradient and image fills; strokes, shadows, blur, opacity, blending and transforms | +| Text | Paragraphs, styled runs, alignment, sizing, decorations and original font references | +| Layout | Constraints, guides, display grids and supported flex/grid layout with fixed, content-sized and fractional tracks | +| Components | Local main components, linked copies, overrides and variant sets; unresolved external copies remain independent with a warning | +| Tokens | Supported scalar tokens, aliases, theme modes, expressions and matching property bindings; literal compound typography/shadow tokens can become native styles | +| Prototypes | Flows, supported pointer/delay triggers, navigation, open/close overlays, back and links, with supported transitions | + +Penpot source ids and selected source properties remain in layer metadata. +Fingerprints let export reuse unchanged text, layout, strokes, interactions and +tokens instead of regenerating them. Edited supported values are generated from +FigCraft's model. New layers receive deterministic UUIDs. + +## Fonts + +Embedded TTF/OTF/TTC/WOFF signatures are detected without decoding their payloads. +The current document model does not retain font binaries; import reports this +limitation. Install licensed fonts locally when needed. Native saves retain the +original family names and bounded provider metadata, rather than the binaries. + +Provider references never trigger downloads. `file.fontReport` reports availability +and distinguishes Google provider references from custom library references whose +original binary cannot be verified. Missing fonts use local fallback while their +original family names remain editable. + +Penpot export writes font references. Install the referenced fonts in the +destination Penpot team. + +## Limits + +- Penpot 1.x binary exports are unsupported; re-export from Penpot 2.x. +- Malformed archives, oversized members and excessive nesting/layer counts are + rejected or bounded. Unsupported features produce import/export warnings. +- ZIP output is limited to 1 GiB of bytes actually read, with at most 16 MiB per + JSON member and 128 MiB of shape JSON per page. All JSON members share a + conservative four-million-value/key budget, checked before allocating parsed + trees. Dense arrays cannot bypass it with small compressed or serialized sizes. +- Token expressions share nesting and work budgets across aliases and theme + modes. Grid-cell membership is indexed once per parent. +- Raw SVG uses FigCraft's SVG importer where possible and retains source metadata. + Complex masks and unsupported geometry may remain placeholders with warnings. +- Penpot export approximates unsupported paints/effects, custom dashes and + transitions; unsupported actions and triggers are reported. Slices are omitted. +- Regenerated text has no Penpot `positionData`; Penpot lays it out again. +- An imported `tokens.json` is preserved when token changes cannot be regenerated; + the exporter reports that limitation. +- Reverse flow, stretch alignment, wrapped-line space-around/space-evenly, + negative padding, item margins/self-alignment and percentage tracks need richer + model support. Their containers keep saved positions with warnings. Bounded + source metadata survives native saves and unchanged Penpot re-export. +- Toggle-overlay has no equivalent in the current model. It is skipped with a + warning, while unchanged source interactions remain available on re-export. +- Embedded font binaries are not retained by native saves or Penpot re-export. + +## Verification + +Regression tests cover supported editable content, unsupported-layout position +and source retention, offline font references, toggle-overlay warnings and the +archive/JSON/token budgets. These checks do not establish live Penpot server or +native macOS/Linux compatibility. + +```sh +cargo xtask ci +cargo test -p figcraft-format penpot +cargo test -p figcraft-engine penpot +``` + +Optional local samples are not distributed or required: + +```sh +FIGCRAFT_PENPOT_SAMPLE=/path/to/example.penpot cargo test -p figcraft-format penpot +FIGCRAFT_PENPOT_SAMPLES=/path/to/folder cargo test -p figcraft-engine penpot_export_tests +``` diff --git a/docs/cli.md b/docs/cli.md index 91288f140..930d4db4e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -49,7 +49,7 @@ page 1 "Screens": 380 nodes, 4 top-level … ``` -### `export -o [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]` +### `export -o [--format png|jpg|svg|webp|pdf|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]` - `` is a directory: one file per top-level frame of the page (default the first page), named after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer. @@ -65,6 +65,13 @@ page 1 "Screens": 380 nodes, 4 top-level documents and small test files, in other software; other apps may not open every file. What was left out (comments, Dev Mode data, some bindings and settings…) is printed as `note:` lines; `docs/file-format-parity.md` lists what was checked and what is unchecked. +- `--format penpot` (or an `` ending in `.penpot`) writes the whole file as a Penpot 2.x + archive (`manifest.json` plus one JSON per file, page and shape) that Penpot imports into a team: + pages, boards, groups, rectangles, ellipses, paths, booleans and text, fills, strokes, shadows and + blurs, flex and grid layout, components and variants, design tokens and prototype interactions. + Layers opened from a `.penpot` keep their Penpot ids, and unchanged text, layouts, interactions + and tokens are written back from the original. What Penpot cannot represent is printed as + `note:` lines. - The format comes from `--format`, else ``'s extension, else png. `--scale` (default 1, max 64) applies to raster formats. - SVG/PDF exports with visible descendants more than 256 levels below an exported root fail with @@ -96,7 +103,8 @@ By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.f shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.tif/.bmp` (an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`, `.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's -frames, one page each), `.fig` (the whole file, experimental, as `export --format fig`). +frames, one page each), `.fig` (the whole file, experimental, as `export --format fig`), `.penpot` +(the whole file as a Penpot 2.x archive, as `export --format penpot`). ### `render -o [--scale N] [--page NAME]` diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d1819ee00..5c94f8f8c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (M6: offline Penpot import/export with existing model and bounded parsing) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,6 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| +| `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](PENPOT_IMPORT.md). | further live Penpot and cross-platform QA | | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | @@ -165,6 +166,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. | | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged | | 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before | | 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards | From 300a5f2f9dda6db7ae26db7320d9a7f9759395f2 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Tue, 27 Oct 2026 07:57:54 +0300 Subject: [PATCH 02/30] M0.9: let batches continue after failed steps (#1386) command.batch and the MCP batch tool take onError: "abort" (the default, unchanged) or "continue". In continue mode each step runs inside a rollback-only checkpoint split out of Session::grouped: a failed step is rolled back on its own, later steps still run, and the result is one {step, command, ok, result|error} object per step. What was applied is one undo step; a reference to a failed step fails too; malformed batches are still rejected before any step runs. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- ROADMAP.md | 3 +- crates/engine/src/automation/batch.rs | 16 +- crates/engine/src/batch_step_ref_tests.rs | 367 ++++++++++++++++++ crates/engine/src/cmd/edit.rs | 11 +- crates/engine/src/session/undo.rs | 182 ++++++--- crates/engine/src/sharing/background/tests.rs | 29 ++ crates/mcp/src/tools.rs | 9 +- crates/mcp/tests/it/protocol.rs | 50 +++ docs/cli.md | 4 + docs/control-protocol.md | 4 + docs/mcp.md | 25 +- docs/roadmap.md | 3 +- 12 files changed, 635 insertions(+), 68 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 74a617f3b..77768db46 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 (optional batch continue-on-error with per-step outcomes (#1386); 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-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. 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. diff --git a/crates/engine/src/automation/batch.rs b/crates/engine/src/automation/batch.rs index 5c4550ecb..54c655484 100644 --- a/crates/engine/src/automation/batch.rs +++ b/crates/engine/src/automation/batch.rs @@ -5,7 +5,7 @@ //! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the //! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde_json::{Map, Value}; @@ -45,6 +45,7 @@ pub fn step_name(step: usize, json: &Value) -> Result> { pub struct BatchScope { names: HashMap, results: Vec, + failed: HashSet, } impl BatchScope { @@ -57,10 +58,10 @@ impl BatchScope { return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name"))); } } - Ok(Self { names, results: Vec::with_capacity(steps.len()) }) + Ok(Self { names, results: Vec::with_capacity(steps.len()), failed: HashSet::new() }) } - /// The results of the steps run so far, in order. + /// The results of the steps run so far, in order (failed slots hold null). pub fn results(&self) -> &[Value] { &self.results } @@ -78,6 +79,12 @@ impl BatchScope { self.results.push(result); } + pub(crate) fn push_failed(&mut self) { + // Keep positions even after a failure; a successful null result is not a failed step. + self.failed.insert(self.next_step()); + self.results.push(Value::Null); + } + /// `params` of the next step with every reference in it replaced. pub(crate) fn resolve(&self, params: &Value) -> Result { self.resolve_in(params, 0) @@ -125,6 +132,9 @@ impl BatchScope { } (n, format!("step {n} (\"{name}\")")) }; + if self.failed.contains(&n) { + return Err(fail(format!("references {who}, which failed"))); + } let path = match o.get("field") { None => "id", Some(Value::String(f)) => f.as_str(), diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index 47d6e8766..f5ce3a831 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -280,3 +280,370 @@ fn a_failing_command_names_its_step_and_rolls_back() { assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name); assert_eq!(s.ds().unwrap().history.undo.len(), undo); } + +#[test] +fn a_continue_batch_can_undo_then_redo_between_steps() { + for mode in ["abort", "continue"] { + let mut s = Session::new(); + let rect = id(&s.execute("node.create", &json!({"type": "rectangle"})).unwrap()); + let before = s.doc().unwrap().clone(); + let results = + s.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "edit.undo"}, {"command": "edit.redo"}]})).unwrap(); + let undo = if mode == "continue" { &results[0]["result"] } else { &results[0] }; + let redo = if mode == "continue" { &results[1]["result"] } else { &results[1] }; + assert_eq!(undo["undone"], true, "{mode}: {results}"); + assert_eq!(redo["redone"], true, "{mode}: {results}"); + assert!(s.doc().unwrap().contains(rect)); + assert_eq!(s.doc().unwrap(), &before); + } +} + +#[test] +fn a_continue_batch_can_open_a_document_after_closing_the_last_tab() { + let mut s = Session::new(); + let uid = s.ds().unwrap().uid; + let results = s + .execute( + "command.batch", + &json!({"onError": "continue", "commands": [ + {"command": "file.close", "params": {"force": true}}, + {"command": "file.new"}, + {"command": "page.rename", "params": {"name": "New page"}} + ]}), + ) + .unwrap(); + assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == true), "{results}"); + assert_eq!(s.docs.len(), 1); + assert_ne!(s.ds().unwrap().uid, uid); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "New page"); + assert!(s.closed_in_group.is_empty()); +} + +#[test] +fn a_continue_batch_repeats_the_last_successful_command_after_a_failed_step() { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle"})).unwrap(); + let last = s.last_command.clone(); + let results = s + .execute( + "command.batch", + &json!({"onError": "continue", "commands": [ + {"command": "command.batch", "params": {"commands": [ + {"command": "node.create", "params": {"type": "text", "text": "Rolled back"}}, + {"command": "no.such.command"} + ]}}, + {"command": "edit.repeat"} + ]}), + ) + .unwrap(); + assert_eq!(results[0]["ok"], false); + assert_eq!(results[1]["ok"], true); + let repeated = s.doc().unwrap().node(id(&results[1]["result"])).unwrap(); + assert!(matches!(repeated.kind, figcraft_doc::NodeKind::Rectangle), "{repeated:?}"); + assert_eq!(s.last_command, last); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 2); +} + +#[test] +fn a_continue_batch_keeps_successful_steps_as_one_undo_entry() { + let mut s = Session::new(); + let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap()); + let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap()); + assert_eq!((frame.0, text.0), (2, 3)); + let undo = s.undo_depth().0; + let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone(); + let results = s + .execute( + "command.batch", + &json!({"label": "Continue", "onError": "continue", "commands": [ + {"command": "text.set", "params": {"ids": [3], "text": "novo"}}, + {"command": "text.set", "params": {"ids": [2], "text": "erro"}}, + {"command": "page.rename", "params": {"name": "Depois do erro"}} + ]}), + ) + .unwrap(); + assert_eq!(results.as_array().unwrap().len(), 3); + assert_eq!(results[0], json!({"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}})); + assert_eq!(results[2], json!({"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}})); + assert_eq!(results[1]["step"], 1); + assert_eq!(results[1]["command"], "text.set"); + assert_eq!(results[1]["ok"], false); + let error = results[1]["error"].as_str().unwrap(); + assert!(error.contains("step 1:") && error.contains("is not text"), "{error}"); + assert!(results[1].get("result").is_none()); + assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo")); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro"); + assert_eq!(s.undo_depth(), (undo + 1, 0)); + assert_eq!(s.undo_labels(1), ["Continue"]); + assert!(s.undo().unwrap()); + assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig")); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name); + assert!(s.doc().unwrap().contains(frame), "undo leaves earlier edits alone"); + assert_eq!(s.undo_depth(), (undo, 1)); + assert!(s.redo().unwrap()); + assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo")); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro"); + assert_eq!(s.undo_depth(), (undo + 1, 0)); +} + +#[test] +fn a_continue_batch_rolls_back_partial_steps_and_keeps_reference_positions() { + let mut s = Session::new(); + let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap()); + let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap()); + let results = s + .execute( + "command.batch", + &json!({"onError": "continue", "commands": [ + {"command": "text.set", "as": "bad", "params": {"ids": [text.0, frame.0], "text": "partial"}}, + {"command": "node.create", "as": "card", "params": {"type": "frame"}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$ref": "bad"}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 1}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}}, + {"command": "node.set", "params": {"ids": [{"$step": 4}], "props": {"name": "Child"}}} + ]}), + ) + .unwrap(); + assert_eq!(results.as_array().unwrap().len(), 7); + for (i, result) in results.as_array().unwrap().iter().enumerate() { + assert_eq!(result["step"], i); + assert_eq!(result["ok"], ![0, 2, 3].contains(&i)); + } + assert!(results[2]["error"].as_str().unwrap().contains("step 2: references step 0, which failed")); + assert!(results[3]["error"].as_str().unwrap().contains("step 3: references step 0 (\"bad\"), which failed")); + assert!( + matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"), + "the failed step's partial edit was rolled back" + ); + let card = id(&results[1]["result"]); + for i in [4, 5] { + assert_eq!(s.doc().unwrap().node(id(&results[i]["result"])).unwrap().parent, Some(card), "references use raw results, not outcomes"); + } + assert_eq!(s.doc().unwrap().node(id(&results[4]["result"])).unwrap().name, "Child"); +} + +#[test] +fn invalid_batch_policies_run_nothing() { + for mode in [json!("skip"), json!(""), json!(true), json!(3), json!(["continue"]), json!({})] { + let mut s = Session::new(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + let error = s + .execute("command.batch", &json!({"onError": mode, "commands": [{"command": "page.rename", "params": {"name": "Changed"}}]})) + .unwrap_err(); + assert!(matches!(error, EngineError::BadParams { .. })); + assert!(error.to_string().contains("`onError` must be \"abort\" or \"continue\""), "{error}"); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.journal, journal); + assert_eq!(s.undo_depth(), (0, 0)); + } +} + +#[test] +fn invalid_batch_structure_runs_nothing_in_either_mode() { + let rename = json!({"command": "page.rename", "params": {"name": "Changed"}}); + for mode in ["abort", "continue"] { + for commands in [ + json!(null), + json!({}), + json!([rename, null]), + json!([rename, {}]), + json!([rename, {"command": 3}]), + json!([rename, {"command": "page.rename", "as": ""}]), + json!([rename, {"command": "page.rename", "as": 3}]), + json!([{"command": "page.rename", "as": "same", "params": {"name": "Changed"}}, {"command": "select.none", "as": "same"}]), + ] { + let mut s = Session::new(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + let result = s.execute("command.batch", &json!({"onError": mode, "commands": commands})); + assert!(matches!(result, Err(EngineError::BadParams { .. })), "{mode}: {result:?}"); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.journal, journal); + assert_eq!(s.undo_depth(), (0, 0)); + } + } +} + +#[test] +fn an_explicit_abort_batch_keeps_the_default_results_and_errors() { + let commands = json!([ + {"command": "node.create", "params": {"type": "frame"}}, + {"command": "node.create", "params": {"type": "text", "text": "orig"}} + ]); + let mut default = Session::new(); + let mut abort = Session::new(); + let raw = default.execute("command.batch", &json!({"commands": commands})).unwrap(); + assert_eq!(abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap(), raw); + assert!(raw[0].get("id").is_some() && raw[0].get("ok").is_none(), "still raw results"); + let commands = json!([ + {"command": "text.set", "params": {"ids": [3], "text": "novo"}}, + {"command": "text.set", "params": {"ids": [2], "text": "erro"}}, + {"command": "page.rename", "params": {"name": "Depois do erro"}} + ]); + let before = abort.doc().unwrap().clone(); + let undo = abort.undo_depth(); + let error = default.execute("command.batch", &json!({"commands": commands})).unwrap_err(); + let explicit = abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap_err(); + assert_eq!(explicit.to_string(), error.to_string()); + assert!(matches!(explicit, EngineError::BadParams { .. })); + assert_eq!(abort.doc().unwrap(), &before); + assert_eq!(abort.undo_depth(), undo); +} + +#[test] +fn nested_batch_error_policies_are_local() { + for outer in ["abort", "continue"] { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"onError": outer, "commands": [ + {"command": "command.batch", "as": "inner", "params": {"onError": "continue", "commands": [ + {"command": "node.create", "as": "card", "params": {"type": "frame"}}, + {"command": "no.such.command"}, + {"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}} + ]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "2/result/id"}], "props": {"name": "Inner child"}}} + ]}), + ) + .unwrap(); + let inner = if outer == "continue" { &results[0]["result"] } else { &results[0] }; + assert_eq!(inner[1]["ok"], false); + let child = s.doc().unwrap().node(id(&inner[2]["result"])).unwrap(); + assert_eq!(child.name, "Inner child"); + assert_eq!(child.parent, Some(id(&inner[0]["result"]))); + assert_eq!(s.undo_depth(), (1, 0)); + assert!(s.undo().unwrap()); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 0); + assert!(s.redo().unwrap()); + assert!(s.doc().unwrap().contains(id(&inner[2]["result"]))); + } +} + +#[test] +fn an_outer_continue_batch_rolls_back_a_failed_inner_abort_batch() { + for explicit in [false, true] { + let mut s = Session::new(); + let mut inner = json!({"commands": [ + {"command": "file.close", "params": {"force": true}}, + {"command": "file.new", "params": {"name": "Temporary"}}, + {"command": "node.create", "params": {"type": "frame"}}, + {"command": "no.such.command"} + ]}); + if explicit { + inner["onError"] = json!("abort"); + } + let uid = s.ds().unwrap().uid; + let results = s + .execute( + "command.batch", + &json!({"onError": "continue", "commands": [ + {"command": "page.rename", "params": {"name": "Before"}}, + {"command": "command.batch", "params": inner}, + {"command": "page.rename", "params": {"name": "After"}} + ]}), + ) + .unwrap(); + assert_eq!(results[1]["ok"], false); + assert!(results[1]["error"].as_str().unwrap().contains("unknown command")); + assert_eq!(results[2]["ok"], true); + assert_eq!(s.docs.len(), 1); + assert_eq!(s.ds().unwrap().uid, uid); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After"); + assert!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.is_empty()); + assert!(s.closed_in_group.is_empty()); + assert_eq!(s.undo_depth(), (1, 0)); + } +} + +#[test] +fn an_outer_abort_batch_rolls_back_successes_of_an_inner_continue_batch() { + let mut s = Session::new(); + let before = s.doc().unwrap().clone(); + let result = s.execute( + "command.batch", + &json!({"commands": [ + {"command": "command.batch", "params": {"onError": "continue", "commands": [ + {"command": "node.create", "params": {"type": "frame"}}, + {"command": "no.such.command"} + ]}}, + {"command": "no.such.command"} + ]}), + ); + assert!(matches!(result, Err(EngineError::UnknownCommand(_)))); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_depth(), (0, 0)); +} + +#[test] +fn an_all_failed_continue_batch_adds_no_undo_and_keeps_redo() { + let mut s = Session::new(); + let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap()); + s.execute("page.rename", &json!({"name": "Redo name"})).unwrap(); + assert!(s.undo().unwrap()); + let depth = s.undo_depth(); + let before = s.doc().unwrap().clone(); + let results = s + .execute( + "command.batch", + &json!({"onError": "continue", "commands": [ + {"command": "node.set", "params": {"ids": [frame.0, 999999], "props": {"name": "Partial"}}}, + {"command": "no.such.command"}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}} + ]}), + ) + .unwrap(); + assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == false)); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_depth(), depth); + assert!(s.redo().unwrap()); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Redo name"); +} + +#[test] +fn an_empty_continue_batch_returns_an_empty_array_without_undo() { + let mut s = Session::new(); + assert_eq!(s.execute("command.batch", &json!({"onError": "continue", "commands": []})).unwrap(), json!([])); + assert_eq!(s.undo_depth(), (0, 0)); +} + +#[test] +fn a_null_batch_result_is_not_a_failed_slot() { + let mut scope = crate::BatchScope::default(); + scope.push(serde_json::Value::Null); + scope.push_failed(); + let null = scope.resolve(&json!({"$step": 0})).unwrap_err().to_string(); + assert!(null.contains("step 0's result has no `id`") && !null.contains("which failed"), "{null}"); + let failed = scope.resolve(&json!({"$step": 1})).unwrap_err().to_string(); + assert!(failed.contains("step 2: references step 1, which failed"), "{failed}"); + assert_eq!(scope.next_step(), 2); +} + +#[test] +fn a_continue_batch_reports_runtime_errors_and_runs_later_steps() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"onError": "continue", "commands": [ + {"command": "no.such.command"}, + {"command": "node.create", "params": {"type": "no-such-type"}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$ref": "missing"}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 99}}}, + {"command": "node.create", "params": {"type": "frame"}}, + {"command": "node.set", "params": {"ids": [{"$step": 4, "field": "missing"}], "props": {"name": "No"}}}, + {"command": "page.rename", "params": {"name": "Done"}} + ]}), + ) + .unwrap(); + assert_eq!(results.as_array().unwrap().len(), 7); + for (i, needle) in [(0, "unknown command"), (1, "unknown type"), (2, "names no step"), (3, "earlier step"), (5, "has no `missing`")] { + assert_eq!(results[i]["ok"], false); + assert!(results[i]["error"].as_str().unwrap().contains(needle), "{}", results[i]); + } + assert_eq!(results[4]["ok"], true); + assert_eq!(results[6]["ok"], true); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Done"); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7ec63c698..b8fa5207c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -376,7 +376,14 @@ pub fn specs() -> Vec { } ok() }), - spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| { + spec!("command.batch", "Run several commands", "{label?, onError?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| { + // `continue` rolls back only the failed steps and returns one outcome per step. + let continue_on_error = match get(p, "onError") { + None => false, + Some(Value::String(mode)) if mode == "abort" => false, + Some(Value::String(mode)) if mode == "continue" => true, + Some(_) => return Err(bad("command.batch", "`onError` must be \"abort\" or \"continue\"")), + }; let label = string(p, "label").unwrap_or_else(|| "Batch".into()); let cmds: Vec = match get(p, "commands") { Some(Value::Array(a)) => { @@ -391,7 +398,7 @@ pub fn specs() -> Vec { } _ => return Err(bad("command.batch", "`commands` must be an array")), }; - Ok(Value::Array(s.batch(&label, &cmds)?)) + Ok(Value::Array(if continue_on_error { s.batch_continue(&label, &cmds)? } else { s.batch(&label, &cmds)? })) }), spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| { let Some((id, p)) = s.last_command.clone().filter(|(id, _)| id != "edit.repeat") else { diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index a354ea27b..e7c3d1ea8 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -1,5 +1,22 @@ use super::*; +struct GroupDocCheckpoint { + uid: u64, + doc: Arc, + selection: Vec, + page: NodeId, + undo_len: usize, + redo: Vec, + limit: usize, +} + +struct GroupCheckpoint { + docs: Vec, + active: usize, + journal_len: usize, + last_command: Option<(String, Value)>, +} + impl Session { /// How many undo and redo steps the active document has (the user's own in a live session). pub fn undo_depth(&self) -> (usize, usize) { @@ -176,18 +193,45 @@ impl Session { }) } + /// Run every step, rolling back failed steps and returning each outcome, still as one undo step. + pub(crate) fn batch_continue(&mut self, label: &str, steps: &[BatchStep]) -> Result> { + let mut scope = BatchScope::new(steps)?; + self.grouped(label, |s| { + let mut outcomes = Vec::with_capacity(steps.len()); + for (i, step) in steps.iter().enumerate() { + let outcome = match s.checkpointed(None, |s| s.execute_batch_step(&scope, &step.command, &step.params)) { + Ok(v) => { + scope.push(v.clone()); + json!({"step": i, "command": step.command, "ok": true, "result": v}) + } + Err(EngineError::Waiting) => return Err(EngineError::Waiting), + Err(e) => { + scope.push_failed(); + json!({"step": i, "command": step.command, "ok": false, "error": e.to_string()}) + } + }; + outcomes.push(outcome); + } + Ok(outcomes) + }) + } + /// The next step of a batch: the references in `params` resolve against `scope` (the steps /// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list /// of these as one undo step; a caller with its own list — the CLI's script runner — uses this /// so its steps resolve references the same way, inside its own [`Session::grouped`]. pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> { - let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? }; - // Say which step failed; a nested batch names its own steps. - let v = self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) })?; + let v = self.execute_batch_step(scope, cmd, params)?; scope.push(v); Ok(()) } + fn execute_batch_step(&mut self, scope: &BatchScope, cmd: &str, params: &Value) -> Result { + let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? }; + // Say which step failed; a nested batch names its own steps. + self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) }) + } + /// Take the tabs `pred` matches out of `docs`; inside a group they are kept so that a failed /// group can reopen them. pub(crate) fn remove_tabs(&mut self, mut pred: impl FnMut(&DocState) -> bool) { @@ -202,74 +246,96 @@ impl Session { /// own, and when it fails the documents stay at their group-start state. pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { self.ds()?; - let before: Vec<_> = - self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect(); - // An edit inside clears the redo steps; a failed group has to bring them back. - let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect(); - let active = self.active; - let journal_len = self.journal.len(); + self.checkpointed(Some(label), f) + } - // The steps inside are dropped again below, so they mustn't push the oldest ones out. - for ds in &mut self.docs { - ds.history.limit = usize::MAX; - } - for (uid, _, _, _, _, _) in &before { - self.live_hold(*uid, true); - } - self.group_depth += 1; + /// Without a label, only roll back failures: leave successful steps' history to the outer group. + /// A checkpoint also works between closing the last tab and opening another one. + fn checkpointed(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result) -> Result { + let before = self.group_checkpoint(); let result = f(self); self.group_depth -= 1; - for (uid, _, _, _, _, _) in &before { - self.live_hold(*uid, false); + for ds in &before.docs { + self.live_hold(ds.uid, false); } if result.is_err() { - // Tabs the group closed come back from the stash, and the group-start order returns. - let mut current = std::mem::take(&mut self.docs); - for (uid, ..) in &before { - let tab = match current.iter().position(|d| d.uid == *uid) { - Some(i) => Some(current.swap_remove(i)), - None => self.closed_in_group.iter().position(|d| d.uid == *uid).map(|i| self.closed_in_group.swap_remove(i)), - }; - self.docs.extend(tab); - } - for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) { - if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) { - ds.history.redo = redo; - ds.doc = doc.clone(); - ds.selection = selection.clone(); - ds.page = *page; - ds.history.undo.truncate(*undo_len); - ds.history.limit = *limit; + self.restore_group_checkpoint(before, label.is_none()); + } else { + let mut changed = Vec::new(); + for saved in &before.docs { + let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) else { continue }; + ds.history.limit = saved.limit; + if label.is_some() { + if !Arc::ptr_eq(&saved.doc, &ds.doc) && ds.interaction.is_none() { + changed.push((saved.uid, saved.doc.clone(), saved.selection.clone(), saved.page)); + } + ds.history.undo.truncate(saved.undo_len); } } - self.active = active; - self.journal.truncate(journal_len); - if self.group_depth == 0 { - self.closed_in_group.clear(); + if let Some(label) = label { + for (uid, doc, selection, page) in changed { + self.record(uid, Some(label), doc, selection, page); + } } - return result; } if self.group_depth == 0 { self.closed_in_group.clear(); } - - let changed: Vec<_> = before - .iter() - .filter_map(|(uid, doc, selection, page, _, _)| { - let ds = self.docs.iter().find(|d| d.uid == *uid)?; - (!Arc::ptr_eq(doc, &ds.doc) && ds.interaction.is_none()).then(|| (*uid, doc.clone(), selection.clone(), *page)) - }) - .collect(); - for ds in &mut self.docs { - if let Some((_, _, _, _, undo_len, limit)) = before.iter().find(|before| before.0 == ds.uid) { - ds.history.limit = *limit; - ds.history.undo.truncate(*undo_len); - } - } - for (uid, doc, selection, page) in changed { - self.record(uid, Some(label), doc, selection, page); - } result } + + fn group_checkpoint(&mut self) -> GroupCheckpoint { + let docs = self + .docs + .iter() + .map(|ds| GroupDocCheckpoint { + uid: ds.uid, + doc: ds.doc.clone(), + selection: ds.selection.clone(), + page: ds.page, + undo_len: ds.history.undo.len(), + // An edit inside clears redo; a failed group has to bring it back. + redo: ds.history.redo.clone(), + limit: ds.history.limit, + }) + .collect(); + let before = GroupCheckpoint { docs, active: self.active, journal_len: self.journal.len(), last_command: self.last_command.clone() }; + // The steps inside are dropped again below, so they mustn't push the oldest ones out. + for ds in &mut self.docs { + ds.history.limit = usize::MAX; + } + for ds in &before.docs { + self.live_hold(ds.uid, true); + } + self.group_depth += 1; + before + } + + fn restore_group_checkpoint(&mut self, before: GroupCheckpoint, restore_last_command: bool) { + // Tabs the group closed come back from the stash, and the group-start order returns. + let mut current = std::mem::take(&mut self.docs); + for saved in &before.docs { + let tab = match current.iter().position(|d| d.uid == saved.uid) { + Some(i) => Some(current.swap_remove(i)), + None => self.closed_in_group.iter().position(|d| d.uid == saved.uid).map(|i| self.closed_in_group.swap_remove(i)), + }; + self.docs.extend(tab); + } + for saved in before.docs { + if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) { + ds.history.redo = saved.redo; + ds.doc = saved.doc; + ds.selection = saved.selection; + ds.page = saved.page; + ds.history.undo.truncate(saved.undo_len); + ds.history.limit = saved.limit; + } + } + self.active = before.active; + self.journal.truncate(before.journal_len); + if restore_last_command { + self.last_command = before.last_command; + } + } } diff --git a/crates/engine/src/sharing/background/tests.rs b/crates/engine/src/sharing/background/tests.rs index c3e5c48e6..d71a44bef 100644 --- a/crates/engine/src/sharing/background/tests.rs +++ b/crates/engine/src/sharing/background/tests.rs @@ -129,6 +129,35 @@ fn published() -> Vec { archive(s.doc().unwrap().published.as_ref().unwrap()).unwrap() } +#[test] +fn a_continue_batch_waits_instead_of_skipping_background_work() { + for nested in [false, true] { + let (mut s, wire) = session(true); + let before = s.doc().unwrap().clone(); + let request = json!({"command": "plugin.directory.list", "params": {"server": SERVER}}); + let step = if nested { json!({"command": "command.batch", "params": {"onError": "continue", "commands": [request]}}) } else { request }; + let params = json!({"onError": "continue", "commands": [ + {"command": "page.rename", "params": {"name": "Before request"}}, + step, + {"command": "page.rename", "params": {"name": "After request"}} + ]}); + assert_eq!(s.start_background("command.batch", params).unwrap(), None); + assert!(s.waiting_for("command.batch")); + assert_eq!(s.doc().unwrap(), &before, "waiting rolls back the whole batch"); + assert_eq!(s.undo_depth(), (0, 0)); + assert_eq!(answer(&mut s, &wire), 1); + let (command, result) = s.take_background_done().unwrap(); + assert_eq!(command, "command.batch"); + let result = result.unwrap(); + assert!(result.as_array().unwrap().iter().all(|r| r["ok"] == true)); + let directory = if nested { &result[1]["result"][0]["result"] } else { &result[1]["result"] }; + assert_eq!(directory["plugins"][0]["id"], ID); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After request"); + assert_eq!(s.undo_depth(), (1, 0)); + assert!(!s.waiting_for("command.batch")); + } +} + #[test] fn browsing_and_installing_wait_for_nothing_and_scripts_still_wait() { let (mut s, wire) = session(true); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1b23191e9..ee9fcbfc5 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -128,11 +128,12 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.", + "Run several commands in order as ONE undo step (engine `command.batch`). By default (onError: \"abort\"), if any command fails, everything is rolled back and the error names the failing step; success returns an array of the results in order. With onError: \"continue\", a failed step is rolled back on its own, the later steps still run, the call is not an error, and the array instead holds one {step, command, ok, result} or {step, command, ok, error} object per step; a reference to a failed step fails that step too. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.", obj( json!({ "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])}, "label": string("Undo history label (default \"Batch\")"), + "onError": {"type": "string", "enum": ["abort", "continue"], "description": "abort (default) rolls back the whole batch on the first failure; continue rolls back only the failed step, runs the rest and returns one {step, command, ok, result|error} object per step"}, }), &["commands"], ), @@ -521,7 +522,11 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Result) -> Result { diff --git a/crates/mcp/tests/it/protocol.rs b/crates/mcp/tests/it/protocol.rs index 617790f43..abb9e370f 100644 --- a/crates/mcp/tests/it/protocol.rs +++ b/crates/mcp/tests/it/protocol.rs @@ -50,6 +50,56 @@ fn tools_list_is_complete_and_schemas_are_objects() { } } +#[test] +fn batch_schema_exposes_the_optional_error_policy() { + let mut c = Client::new(); + let r = c.rpc("tools/list", json!({})); + let batch = r["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "batch").unwrap(); + let schema = &batch["inputSchema"]; + let mode = &schema["properties"]["onError"]; + assert_eq!(mode["type"], "string"); + assert_eq!(mode["enum"], json!(["abort", "continue"])); + assert!(mode["description"].as_str().unwrap().contains("default")); + assert!(!schema["required"].as_array().unwrap().contains(&json!("onError"))); +} + +#[test] +fn a_continue_batch_returns_outcomes_without_a_tool_error() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame"})); + let text = c.create(json!({"type": "text", "text": "orig"})); + assert_eq!((frame.clone(), text.clone()), (json!(2), json!(3))); + let commands = json!([ + {"command": "text.set", "params": {"ids": [3], "text": "novo"}}, + {"command": "text.set", "as": "bad", "params": {"ids": [2], "text": "erro"}}, + {"command": "page.rename", "params": {"name": "Depois do erro"}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$ref": "bad"}}} + ]); + let r = c.call("batch", json!({"onError": "continue", "commands": commands})); + assert_eq!(r["isError"], false); + let outcomes: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(outcomes.as_array().unwrap().len(), 4); + assert_eq!(outcomes[0], json!({"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}})); + assert_eq!(outcomes[2], json!({"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}})); + assert_eq!(outcomes[1]["ok"], false); + assert_eq!(outcomes[1]["command"], "text.set"); + assert_eq!(outcomes[1]["step"], 1); + assert!(outcomes[1]["error"].as_str().unwrap().contains("is not text")); + assert!(outcomes[3]["error"].as_str().unwrap().contains("step 3: references step 1 (\"bad\"), which failed")); + assert_eq!(c.node(&text)["characters"], "novo"); + assert_eq!(c.doc()["pages"][0]["name"], "Depois do erro"); + assert_eq!(c.page_children().len(), 2, "failed steps created nothing"); + c.ok("undo", json!({})); + assert_eq!(c.node(&text)["characters"], "orig"); + c.ok("redo", json!({})); + assert_eq!(c.node(&text)["characters"], "novo"); + assert!(c.err("batch", json!({"onError": "skip", "commands": commands})).contains("`onError`")); + // A null policy is the default: the failed step aborts the whole batch. + assert!(c.err("batch", json!({"onError": null, "commands": commands})).contains("is not text")); + let run = c.run("command.batch", json!({"onError": "continue", "commands": [{"command": "no.such.command"}]})); + assert_eq!(run[0]["ok"], false, "run_command forwards the policy too"); +} + #[test] fn tools_call_happy_and_error_paths() { let mut c = Client::new(); diff --git a/docs/cli.md b/docs/cli.md index 39863bea6..b99e468aa 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -151,6 +151,10 @@ created ([Step references](mcp.md#step-references) has the rules): The result lines are printed once the script is through, so none of them reports work a later step took back. +To keep going past a failed command, make a step a `command.batch` with `"onError": "continue"` +([MCP server](mcp.md) has the rules). The script itself stays all-or-nothing: if a +later top-level step fails, that batch's edits are rolled back with everything else. + A failing step prints one line with `"ok": false`, the error and `"rolledBack": true`, writes nothing (`--out` is not saved, and the session's documents go back to how the script found them), and exits with code 1: diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 448a9979d..1fce1778e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -106,6 +106,10 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"] {"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}} ``` +With `"onError": "continue"` in its params, `command.batch` rolls back only the steps that fail, +runs the rest and returns one `{step, command, ok, result}` or `{step, command, ok, error}` object +per step; the request succeeds even when some steps fail (see [MCP server](mcp.md)). + Inside `command.batch`, a step can name itself with `"as"` and later steps use its result: `{"$ref": "name"}` in a step's params stands for the `id` in that step's result, and `{"$step": n}` for the `id` in step `n`'s result (zero-based). Either takes `"field"` for another key or a slash diff --git a/docs/mcp.md b/docs/mcp.md index 05dfd1e61..d89d46e9a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1931,6 +1931,26 @@ Align and distribute as one undo step: {"command":"distribute.horizontal","params":{"ids":[3,4,5]}}]}}} ``` +By default (`"onError": "abort"`) the first failure rolls back the whole batch, and success +returns the raw results in order. With `"onError": "continue"` next to `commands`, a failed step's +document edits are rolled back on their own, the later steps still run, and the result is instead +one object per step: + +```json +[ + {"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}}, + {"step": 1, "command": "text.set", "ok": false, "error": "invalid parameters for `text.set`: step 1: node 2 is not text"}, + {"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}} +] +``` + +The tool call is not an error (`isError` is false) even when some steps fail. Successful document +edits form one undo step; if every step fails, none is added. Unknown commands, bad params and bad +references fail only their step, but a malformed `commands` list, an invalid `onError` or two steps +with the same `as` still reject the whole batch before any step runs. A step that has to wait for +background work makes the whole call wait, as without `onError`. A nested batch has its own +`onError`. + ### Step references A step in a `batch` can use what an earlier step returned. Give the step a name with `"as"` (next to @@ -1968,7 +1988,10 @@ These fail the whole batch, which is rolled back, with an error naming the step: same `as` (before any step runs); a `$ref` to a name no step has (the error lists the names there are) or to the same or a later step; a `$step` to the same or a later step, or that is negative or not an integer; a `field` the result doesn't have (the error names the full path and, for a list, -how many items it has); any key next to `$ref` or `$step` other than `field`. Only an object holding +how many items it has); any key next to `$ref` or `$step` other than `field`. With `"onError": +"continue"` all but the duplicate names fail only their step, and so does a reference to a step that +failed (the error names both). Steps keep their positions, and references read the raw results. Only +an object holding a `$ref` or `$step` key is a reference: text such as `"$ref card"` is left as written. Both keys are reserved in step params: an object holding either anywhere (in plugin data or a variable value too) is read as a reference. A step that is itself a `command.batch` is passed on as written, so the diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba5..828604fb5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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 (M0.9: optional batch continue-on-error with per-step outcomes (#1386); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return per-step outcomes; references to failed steps fail with both step numbers, successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | From 0d4756473740461df2d7672904df406e68b1cdba Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Tue, 27 Oct 2026 12:55:48 +0300 Subject: [PATCH 03/30] M3: bind fill and stroke variables directly in layer props (#1000) node.create and node.set take {"$var": variable id or name} for fill and stroke: an exact id first, then an unambiguous name or Collection/name. The binding goes through the same code as variable.bind, is applied after literal values and style links, replaces other bindings of that paint's colour and clears its style link, all in one undo step. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- crates/engine/src/cmd/edit/props.rs | 3 + crates/engine/src/cmd/props.rs | 44 +- crates/engine/src/cmd/styles.rs | 39 +- crates/engine/src/cmd/variables.rs | 28 ++ crates/engine/src/lib.rs | 2 + .../engine/src/selection/selection_colors.rs | 11 +- crates/engine/src/tests.rs | 5 + crates/engine/src/variable_props_tests.rs | 389 ++++++++++++++++++ docs/mcp.md | 8 +- docs/roadmap.md | 3 +- 10 files changed, 505 insertions(+), 27 deletions(-) create mode 100644 crates/engine/src/variable_props_tests.rs diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index f793b50ce..97e88409d 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -104,6 +104,9 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result { if props.is_empty() { return Err(bad("node.set", "no properties given")); } + for (k, v) in &props { + crate::cmd::props::variable_prop("node.set", k, v)?; + } // A new rotation for the selection turns it about its moved rotation origin: each layer's // point under the origin stays put. let ds = s.ds()?; diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 30a103932..ae2d842b4 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, {$var: variable id or name}, or \"none\"/null: no fill), strokes, stroke (colour, {$var: variable id or name}, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ @@ -383,10 +383,31 @@ fn paint_none(v: &Value) -> bool { v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none")) } +/// A top-level variable wrapper. Validate before `node.set` skips type-specific properties too, +/// so an unsupported wrapper is always an error, even on a mixed selection. +pub(crate) fn variable_prop<'a>(cmd: &str, key: &str, value: &'a Value) -> Result> { + let Some(o) = value.as_object().filter(|o| o.contains_key("$var")) else { return Ok(None) }; + if !matches!(key, "fill" | "stroke") { + return Err(bad(cmd, format!("`{key}`: only `fill` and `stroke` take variables in props; use `variable.bind` for other fields"))); + } + if o.len() != 1 { + return Err(bad(cmd, format!("`{key}`: a `$var` wrapper takes only `$var`, with no extra keys"))); + } + let name = o + .get("$var") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| bad(cmd, format!("`{key}`: `$var` must be a variable id or name (a non-empty string)")))?; + Ok(Some(name)) +} + /// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style /// links after the values so a link wins over values its style sets, variable bindings last so -/// paints set in the same call don't detach them. -fn apply_order(k: &str) -> u8 { +/// paints set in the same call don't detach them. `$var` wrappers also win over style links. +fn apply_order(k: &str, v: &Value) -> u8 { + if v.get("$var").is_some() { + return 6; + } match k { "path" | "width" | "height" | "w" | "h" => 0, "rotation" => 2, @@ -413,11 +434,26 @@ fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); let mut keys: Vec<(&String, &Value)> = props.iter().collect(); - keys.sort_by_key(|(k, _)| apply_order(k)); + keys.sort_by_key(|(k, v)| apply_order(k, v)); // Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it). let mut path_offset = (0.0, 0.0); for (k, v) in keys { let k = k.as_str(); + if let Some(name) = variable_prop(cmd, k, v)? { + let vid = super::variables::resolve_strict(cmd, doc, name)?; + // All aliases of the first paint name one slot; other paint slots stay bound. + super::styles::unbind_slot(doc.get_mut(id)?, k == "fill", 0); + super::styles::bind_variable(cmd, doc, &[id], k, Some(&vid))?; + // A paint bound through props follows the variable, not its former style. Keep this + // local to `$var`, including when the style and variable happen to give the same colour. + let n = doc.get_mut(id)?; + if k == "fill" { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + continue; + } match k { "path" => path_offset = set_path(cmd, doc, id, v, props)?, // A new path takes the size asked for as its scale (`set_path`); the size still fixes diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 9c2922d21..c59f67f1e 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -772,13 +772,20 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { Some(v) => Some(doc.variables.get(v).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such variable"))?), None => None, }; + bind_variable(cmd, doc, &ids, &field, vid.as_deref())?; + ok() +} + +/// Bind an already resolved variable id, or unbind, inside the caller's transaction. Props and +/// `variable.bind` share type checks and paint alpha handling; normalisation applies the value. +pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field: &str, vid: Option<&str>) -> Result<()> { // An instance's component property takes a variable of its type; other fields the types their // scopes belong to. - if let Some(v) = vid.as_ref().and_then(|v| doc.variables.get(v)) { - for i in &ids { + if let Some(v) = vid.and_then(|v| doc.variables.get(v)) { + for i in ids { match field.strip_prefix(crate::components::PROP_BINDING) { Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?, - None if !super::variables::field_takes(doc, *i, &field, v.kind) => { + None if !super::variables::field_takes(doc, *i, field, v.kind) => { return Err(bad(cmd, format!("`{field}` doesn't take a {} variable", v.kind.id()))); } None => {} @@ -786,22 +793,32 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { } } for i in ids { - let n = doc.get_mut(i)?; - match &vid { + let n = doc.get_mut(*i)?; + match vid { Some(v) => { - if n.bound.get(&field) != Some(v) { - rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true); + if n.bound.get(field).map(String::as_str) != Some(v) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); } - n.bound.insert(field.clone(), v.clone()); + n.bound.insert(field.to_string(), v.to_string()); } None => { - if n.bound.remove(&field).is_some() { - rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false); + if n.bound.remove(field).is_some() { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); } } } } - ok() + Ok(()) +} + +/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when +/// one was there. +pub(crate) fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool { + use super::lists::{BoundList, list_binding}; + let list = if fills { BoundList::Fills } else { BoundList::Strokes }; + let before = n.bound.len(); + n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color"))); + n.bound.len() != before } /// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 53b62ee46..5a3b7b1cb 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -43,6 +43,34 @@ pub fn specs() -> Vec { ] } +/// Props resolve an exact id first, then an unambiguous exact name or `Collection/name`. Compare +/// whole names: both collection and variable names can contain `/`. Other commands keep their +/// existing first-match lookup. +pub(crate) fn resolve_strict(cmd: &str, doc: &Document, name: &str) -> Result { + let vars = &doc.variables; + if let Some(v) = vars.variables.iter().find(|v| v.id == name) { + return Ok(v.id.clone()); + } + let collections: std::collections::BTreeMap<_, _> = vars.collections.iter().map(|c| (c.id.as_str(), c.name.as_str())).collect(); + let mut candidates = std::collections::BTreeMap::new(); + for v in &vars.variables { + let collection = collections.get(v.collection.as_str()).copied().unwrap_or(v.collection.as_str()); + let qualified = format!("{collection}/{}", v.name); + if v.name == name || qualified == name { + candidates.insert(v.id.as_str(), qualified); + } + } + match candidates.keys().next().copied() { + None => Err(bad(cmd, format!("no such variable `{name}`"))), + Some(id) if candidates.len() == 1 => Ok(id.to_string()), + Some(_) => { + let mut names: Vec<_> = candidates.into_values().collect(); + names.sort(); + Err(bad(cmd, format!("ambiguous variable `{name}`: {} (use a variable id to choose)", names.join(", ")))) + } + } +} + /// The first of `base`, `base 2`, `base 3`… that no collection is named. pub(crate) fn free_collection_name(doc: &Document, base: &str) -> String { (1..1000) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 60f240cb0..06c86e9f4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -482,6 +482,8 @@ mod tiff_tests; #[cfg(test)] mod transform_behaviour_tests; #[cfg(test)] +mod variable_props_tests; +#[cfg(test)] mod variable_tests; #[cfg(test)] mod variables_behaviour_tests; diff --git a/crates/engine/src/selection/selection_colors.rs b/crates/engine/src/selection/selection_colors.rs index 56bc4b354..3c5eb96c2 100644 --- a/crates/engine/src/selection/selection_colors.rs +++ b/crates/engine/src/selection/selection_colors.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; -use crate::cmd::styles::rebind_paint_alpha; +use crate::cmd::styles::{rebind_paint_alpha, unbind_slot}; use crate::params::*; use crate::{CommandSpec, Result, Session, bad, spec}; @@ -238,15 +238,6 @@ fn list_field(doc: &Document, target: &PaintTarget) -> Option { Some(format!("{}/{}/color", list.prefix(), target.index)) } -/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when -/// one was there. -fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool { - let list = if fills { BoundList::Fills } else { BoundList::Strokes }; - let before = n.bound.len(); - n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color"))); - n.bound.len() != before -} - /// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a /// variable that isn't a color, or a gradient. Scopes don't refuse, as with `variable.bind`; they /// only filter what the menu offers ([`in_scope`]). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 811724f2d..d5d6b59ad 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -404,6 +404,11 @@ fn every_command_survives_junk_params() { json!({"variations": {"wght": 1e308, "": "x", "slnt": null}, "props": {"fontVariations": [1], "variationSettings": {"wdth": "-1e9"}}, "text": 7, "cursor": -3}), json!({"style": 3, "props": {"listStyle": 5, "indentation": 1e308, "listSpacing": "NaN", "hangingList": "yes", "start": -5, "end": 1e12, "textDecorationThickness": {"unit": "pixels", "value": "x"}, "textDecorationOffset": -1e308, "textDecorationColor": [1]}}), + // Paint variable props: malformed wrappers, unsupported fields and mixed references. + json!({"id": 2, "type": "rectangle", "props": {"fill": {"$var": null}, "stroke": {"$var": 7}}}), + json!({"id": 2, "type": "rectangle", "props": {"fill": {"$var": ""}, "stroke": {"$var": []}}}), + json!({"id": 2, "type": "rectangle", "props": {"fill": {"$var": "x", "extra": true}, "width": {"$var": "x"}}}), + json!({"id": 2, "type": "rectangle", "props": {"fill": {"$var": {"$ref": "x"}}, "stroke": {"$var": "x", "$ref": "x"}}}), // Templates and the Getting started tips as junk. json!({"template": {"id": 1}, "name": [1e308], "show": "yes"}), // Links (dev resources) and indexes as junk. diff --git a/crates/engine/src/variable_props_tests.rs b/crates/engine/src/variable_props_tests.rs new file mode 100644 index 000000000..0d4790d5c --- /dev/null +++ b/crates/engine/src/variable_props_tests.rs @@ -0,0 +1,389 @@ +//! Top-level paint variable props share `variable.bind` behaviour in one transaction (#1000). + +use figcraft_color::Color; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::{Session, params::id_of}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) +} + +fn setup() -> (Session, String) { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "palette/surface", "type": "color", "value": "#0D99FF80"})); + (s, v["id"].as_str().unwrap().to_string()) +} + +fn create(s: &mut Session, props: Value) -> NodeId { + id_of(&run(s, "node.create", json!({"type": "rectangle", "width": 10, "height": 10, "props": props}))["id"]).unwrap() +} + +fn color(s: &Session, id: NodeId, field: &str) -> Color { + let n = s.doc().unwrap().node(id).unwrap(); + let paints = if field == "fill" { &n.fills } else { &n.strokes }; + paints[0].solid_color().unwrap() +} + +fn assert_bound(s: &Session, id: NodeId, field: &str, variable: &str) { + assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get(field).map(String::as_str), Some(variable)); + assert_eq!(color(s, id, field).to_hex(), "0D99FF"); + assert!((color(s, id, field).a - 128.0 / 255.0).abs() < 1e-6); +} + +#[test] +fn create_and_set_take_variable_ids_names_and_collection_names_for_both_paints() { + let (mut s, vid) = setup(); + for field in ["fill", "stroke"] { + for name in [&vid, "palette/surface", "Theme/palette/surface"] { + let id = create(&mut s, json!({field: {"$var": name}})); + assert_bound(&s, id, field, &vid); + let id = create(&mut s, json!({"fill": "none", "stroke": "none"})); + run(&mut s, "node.set", json!({"id": id, "props": {field: {"$var": name}}})); + assert_bound(&s, id, field, &vid); + } + } +} + +#[test] +fn ambiguous_and_unknown_variable_names_are_errors_but_ids_still_work() { + let (mut s, vid) = setup(); + run(&mut s, "variable.createCollection", json!({"name": "Other"})); + run(&mut s, "variable.create", json!({"collection": "Other", "name": "palette/surface", "type": "color", "value": "#FF0000"})); + let e = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": "palette/surface"}}})).unwrap_err().to_string(); + assert!(e.contains("ambiguous variable `palette/surface`"), "{e}"); + assert!(e.contains("Other/palette/surface") && e.contains("Theme/palette/surface"), "{e}"); + let id = create(&mut s, json!({"fill": {"$var": vid}})); + assert_bound(&s, id, "fill", &vid); + let id = create(&mut s, json!({"fill": {"$var": "Theme/palette/surface"}})); + assert_bound(&s, id, "fill", &vid); + let e = s.execute("node.set", &json!({"id": id, "props": {"fill": {"$var": "missing/surface"}}})).unwrap_err().to_string(); + assert!(e.contains("no such variable `missing/surface`"), "{e}"); + // The existing command still uses the first match, not the new strict resolver. + run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "palette/surface"})); + assert_bound(&s, id, "fill", &vid); +} + +#[test] +fn variable_ids_choose_between_variables_with_the_same_name() { + let (mut s, first) = setup(); + run(&mut s, "variable.createCollection", json!({"name": "Other"})); + let second = run(&mut s, "variable.create", json!({"collection": "Other", "name": "palette/surface", "type": "color", "value": "#FF0000"}))["id"] + .as_str() + .unwrap() + .to_string(); + let id = create(&mut s, json!({"fill": {"$var": second}})); + assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill"), Some(&second)); + assert_eq!(color(&s, id, "fill").to_hex(), "FF0000"); + // `variable.bind` deliberately keeps its first id-or-name match. + run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "palette/surface"})); + assert_bound(&s, id, "fill", &first); + run(&mut s, "node.set", json!({"id": id, "props": {"fill": {"$var": second}}})); + assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill"), Some(&second)); + assert_eq!(color(&s, id, "fill").to_hex(), "FF0000"); +} + +#[test] +fn whole_name_matches_and_qualified_matches_share_one_candidate_set() { + let (mut s, _) = setup(); + run(&mut s, "variable.createCollection", json!({"name": "Other"})); + run(&mut s, "variable.create", json!({"collection": "Other", "name": "Theme/palette/surface", "type": "color", "value": "#FF0000"})); + let e = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": "Theme/palette/surface"}}})).unwrap_err().to_string(); + assert!(e.contains("ambiguous variable"), "{e}"); + assert!(e.contains("Other/Theme/palette/surface") && e.contains("Theme/palette/surface"), "{e}"); + // Collection names can contain slashes too; no splitting at the first slash. + run(&mut s, "variable.createCollection", json!({"name": "Brand/Theme"})); + let v = run(&mut s, "variable.create", json!({"collection": "Brand/Theme", "name": "palette/surface", "type": "color", "value": "#0D99FF80"})); + let id = create(&mut s, json!({"fill": {"$var": "Brand/Theme/palette/surface"}})); + assert_bound(&s, id, "fill", v["id"].as_str().unwrap()); +} + +#[test] +fn wrong_variable_type_rolls_back_create_and_set() { + let (mut s, _) = setup(); + run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20})); + let id = create(&mut s, json!({"fill": "#FF0000"})); + for field in ["fill", "stroke"] { + for command in ["node.create", "node.set"] { + let before = s.doc().unwrap().clone(); + let selection = s.selection().to_vec(); + let depth = s.undo_depth(); + let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": {"name": "must roll back", "fill": {"$var": "palette/surface"}, field: {"$var": "size"}}})).unwrap_err().to_string(); + assert!(e.contains(&format!("`{field}` doesn't take a number variable")), "{e}"); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.selection(), selection); + assert_eq!(s.undo_depth(), depth); + } + } +} + +#[test] +fn variable_props_follow_modes_and_later_literal_paints_detach() { + let (mut s, vid) = setup(); + // Start with both paints present: mode alpha on a newly created paint is an existing, + // separate `variable.bind` bug, not part of variable props. + let id = create(&mut s, json!({"strokes": ["#FFFFFF"], "fill": {"$var": vid}, "stroke": {"$var": vid}})); + run(&mut s, "variable.set", json!({"variable": vid, "mode": "Dark", "value": "#20212440"})); + for mode in ["Dark", "Light", "Dark"] { + run(&mut s, "variable.setMode", json!({"collection": "Theme", "mode": mode})); + for field in ["fill", "stroke"] { + let c = color(&s, id, field); + assert_eq!(c.to_hex(), if mode == "Dark" { "202124" } else { "0D99FF" }); + let alpha = if mode == "Dark" { 64.0 } else { 128.0 } / 255.0; + assert!((c.a - alpha).abs() < 1e-6, "{mode}: {c:?}"); + } + } + run(&mut s, "node.set", json!({"id": id, "props": {"fill": "#FF0000", "stroke": "#00FF00"}})); + assert!(s.doc().unwrap().node(id).unwrap().bound.is_empty()); + run(&mut s, "variable.set", json!({"variable": vid, "mode": "Dark", "value": "#000000"})); + assert_eq!(color(&s, id, "fill").to_hex(), "FF0000"); + assert_eq!(color(&s, id, "stroke").to_hex(), "00FF00"); +} + +#[test] +fn create_with_variables_is_one_undo_entry_and_redo_restores_it() { + let (mut s, vid) = setup(); + let before = s.doc().unwrap().clone(); + let depth = s.undo_depth(); + let id = create(&mut s, json!({"fill": {"$var": vid}, "stroke": {"$var": vid}})); + assert_eq!(s.undo_depth(), (depth.0 + 1, 0)); + let after = s.doc().unwrap().clone(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap(), &before); + assert!(!s.doc().unwrap().contains(id)); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc().unwrap(), &after); + assert_bound(&s, id, "fill", &vid); + assert_bound(&s, id, "stroke", &vid); +} + +#[test] +fn paint_variable_props_match_explicit_binding_including_typed_alpha_and_opacity() { + let (mut s, vid) = setup(); + for field in ["fill", "stroke"] { + let list = if field == "fill" { "fills" } else { "strokes" }; + for opacity in [1.0, 0.25] { + let paints = json!({list: [{"type": "solid", "color": "#FF000080", "opacity": opacity}]}); + let explicit = create(&mut s, paints.clone()); + run(&mut s, "variable.bind", json!({"id": explicit, "field": field, "variable": vid})); + let mut props = paints.clone(); + props[field] = json!({"$var": vid}); + let inline = create(&mut s, props); + let set = create(&mut s, paints); + run(&mut s, "node.set", json!({"id": set, "props": {field: {"$var": vid}}})); + for id in [inline, set] { + let doc = s.doc().unwrap(); + let (a, b) = (doc.node(id).unwrap(), doc.node(explicit).unwrap()); + assert_eq!(a.fills, b.fills); + assert_eq!(a.strokes, b.strokes); + assert_eq!(a.bound, b.bound); + assert!((color(&s, id, field).a - 128.0 / 255.0 * opacity as f32).abs() < 1e-6); + } + // Repeating the same binding does not accumulate alpha. + run(&mut s, "node.set", json!({"id": inline, "props": {field: {"$var": vid}}})); + assert_eq!(color(&s, inline, field), color(&s, explicit, field)); + } + } +} + +#[test] +fn variable_props_win_over_literal_paint_lists_and_style_links() { + let (mut s, vid) = setup(); + let st = run(&mut s, "style.create", json!({"type": "paint", "name": "Red", "value": ["#FF000080"]}))["id"].clone(); + for field in ["fill", "stroke"] { + let style_key = if field == "fill" { "fillStyle" } else { "strokeStyle" }; + let list = if field == "fill" { "fills" } else { "strokes" }; + let props = json!({list: ["#00FF00"], style_key: st, field: {"$var": vid}}); + let created = create(&mut s, props.clone()); + let set = create(&mut s, json!({})); + run(&mut s, "node.set", json!({"id": set, "props": props})); + for id in [created, set] { + assert_bound(&s, id, field, &vid); + let n = s.doc().unwrap().node(id).unwrap(); + assert!(if field == "fill" { n.styles.fill.is_none() } else { n.styles.stroke.is_none() }); + } + run(&mut s, "style.update", json!({"style": st, "value": ["#FF00FF"]})); + for id in [created, set] { + assert_bound(&s, id, field, &vid); + } + } +} + +#[test] +fn variable_props_unlink_a_preexisting_paint_style_even_when_the_colour_is_unchanged() { + let (mut s, vid) = setup(); + run(&mut s, "variable.set", json!({"variable": vid, "value": "#FF0000"})); + let style = run(&mut s, "style.create", json!({"type": "paint", "name": "Red", "value": ["#FF0000"]}))["id"].clone(); + for field in ["fill", "stroke"] { + let style_key = if field == "fill" { "fillStyle" } else { "strokeStyle" }; + let id = create(&mut s, json!({style_key: style})); + let n = s.doc().unwrap().node(id).unwrap(); + assert!(if field == "fill" { n.styles.fill.is_some() } else { n.styles.stroke.is_some() }); + run(&mut s, "node.set", json!({"id": id, "props": {field: {"$var": vid}}})); + let n = s.doc().unwrap().node(id).unwrap(); + assert_eq!(n.bound.get(field), Some(&vid)); + assert!(if field == "fill" { n.styles.fill.is_none() } else { n.styles.stroke.is_none() }); + assert_eq!(color(&s, id, field).to_hex(), "FF0000"); + } +} + +#[test] +fn malformed_and_unsupported_variable_props_are_errors_and_leave_no_edit() { + let (mut s, _) = setup(); + let id = create(&mut s, json!({})); + for props in [ + json!({"fill": {"$var": null}}), + json!({"fill": {"$var": 5}}), + json!({"stroke": {"$var": []}}), + json!({"fill": {"$var": {}}}), + json!({"fill": {"$var": ""}}), + json!({"fill": {}}), + json!({"fill": {"$var": "palette/surface", "extra": true}}), + json!({"fill": {"$var": "palette/surface", "$ref": "x"}}), + json!({"fill": {"$var": "palette/surface", "$step": 0}}), + ] { + for command in ["node.create", "node.set"] { + let before = s.doc().unwrap().clone(); + let depth = s.undo_depth(); + let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": props})).unwrap_err().to_string(); + assert!(e.contains("$var") || e.contains("must be a colour"), "{e}"); + assert_eq!(s.doc().unwrap(), &before, "{props}"); + assert_eq!(s.undo_depth(), depth); + } + } + for key in ["width", "fills", "strokes", "opacity", "gap", "path", "pluginData", "fillStyle", "unknown"] { + for command in ["node.create", "node.set"] { + let e = s.execute(command, &json!({"type": "rectangle", "id": id, "props": {key: {"$var": "palette/surface"}}})).unwrap_err().to_string(); + assert!(e.contains("only `fill` and `stroke` take variables in props") && e.contains("variable.bind"), "{e}"); + } + } +} + +#[test] +fn batch_references_resolve_inside_variable_wrappers_and_mixed_keys_roll_back() { + for reference in [json!({"$ref": "surface"}), json!({"$step": 1})] { + let mut s = Session::new(); + let commands = json!([ + {"command": "variable.createCollection", "params": {"name": "Theme"}}, + {"command": "variable.create", "as": "surface", "params": {"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF80"}}, + {"command": "node.create", "params": {"type": "rectangle", "props": {"fill": {"$var": reference}}}} + ]); + let result = run(&mut s, "command.batch", json!({"commands": commands})); + let id = id_of(&result[2]["id"]).unwrap(); + let vid = result[1]["id"].as_str().unwrap(); + assert_bound(&s, id, "fill", vid); + assert_eq!(s.undo_depth(), (1, 0)); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().variables.variables.is_empty()); + assert!(!s.doc().unwrap().contains(id)); + + let before = s.doc().unwrap().clone(); + let mut bad_commands = commands.clone(); + bad_commands[2]["params"]["props"]["fill"] = json!({"$var": "surface", "$ref": "surface"}); + let e = s.execute("command.batch", &json!({"commands": bad_commands})).unwrap_err().to_string(); + assert!(e.contains("not `$var`"), "{e}"); + assert_eq!(s.doc().unwrap(), &before); + } +} + +#[test] +fn variable_props_replace_first_paint_aliases_but_keep_other_slots_bound() { + let (mut s, vid) = setup(); + let old = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "old", "type": "color", "value": "#FF0000"}))["id"] + .as_str() + .unwrap() + .to_string(); + for (field, list, aliases) in [ + ("fill", "fills", vec!["fill", "fills", "fills/0", "fills/0/color", "fills/00/color"]), + ("stroke", "strokes", vec!["stroke", "strokes/0/color", "strokes/00/color"]), + ] { + for alias in aliases { + let first = format!("{list}/0/color"); + let second = format!("{list}/1/color"); + let props = json!({list: ["#FFFFFF", "#FFFFFF"], "boundVariables": {alias: old, second.clone(): old}}); + let set = create(&mut s, props.clone()); + run(&mut s, "node.set", json!({"id": set, "props": {field: {"$var": vid}}})); + let mut props = props; + props[field] = json!({"$var": vid}); + let created = create(&mut s, props); + for id in [set, created] { + assert_bound(&s, id, field, &vid); + let n = s.doc().unwrap().node(id).unwrap(); + assert_eq!(n.bound.len(), 2, "{alias}"); + assert_eq!(n.bound.get(&second), Some(&old)); + if alias != field { + assert!(!n.bound.contains_key(alias), "{alias}"); + } + assert!(!n.bound.contains_key(&first)); + let paints = if field == "fill" { &n.fills } else { &n.strokes }; + assert_eq!(paints[1].solid_color().unwrap().to_hex(), "FF0000"); + } + } + } +} + +#[test] +fn variable_props_bind_a_mixed_selection_and_unsupported_wrappers_leave_no_edit() { + let (mut s, vid) = setup(); + let ids: Vec<_> = ["frame", "rectangle", "text"] + .into_iter() + .map(|kind| id_of(&run(&mut s, "node.create", json!({"type": kind, "width": 10, "height": 10}))["id"]).unwrap()) + .collect(); + run(&mut s, "select.set", json!({"ids": ids})); + run(&mut s, "node.set", json!({"props": {"fill": {"$var": vid}, "stroke": {"$var": vid}}})); + for &id in &ids { + for field in ["fill", "stroke"] { + assert_bound(&s, id, field, &vid); + } + } + let before = s.doc().unwrap().clone(); + let selection = s.selection().to_vec(); + let depth = s.undo_depth(); + let e = s.execute("node.set", &json!({"props": {"name": "must not change", "cornerRadius": {"$var": vid}}})).unwrap_err().to_string(); + assert!(e.contains("only `fill` and `stroke` take variables in props"), "{e}"); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.selection(), selection); + assert_eq!(s.undo_depth(), depth); +} + +#[test] +fn variable_keys_inside_typed_paint_lists_stay_literal_but_bare_wrappers_error() { + let (mut s, vid) = setup(); + let id = create(&mut s, json!({})); + for command in ["node.create", "node.set"] { + // Typed paints ignore extra keys today: `$var` is only a wrapper in top-level props. + let result = run( + &mut s, + command, + json!({"type": "rectangle", "id": id, "props": { + "fills": [{"type": "solid", "color": "#FF0000", "$var": vid}] + }}), + ); + let painted = if command == "node.create" { id_of(&result["id"]).unwrap() } else { id }; + assert_eq!(color(&s, painted, "fill").to_hex(), "FF0000"); + assert!(s.doc().unwrap().node(painted).unwrap().bound.is_empty()); + let before = s.doc().unwrap().clone(); + let depth = s.undo_depth(); + assert!(s.execute(command, &json!({"type": "rectangle", "id": id, "props": {"fills": [{"$var": vid}]}})).is_err()); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_depth(), depth); + } +} + +#[test] +fn variable_bind_keeps_its_legacy_type_lookup_when_a_name_collides_with_an_id() { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let number = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20}))["id"].clone(); + let paint = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FF0000"}))["id"].clone(); + run(&mut s, "variable.update", json!({"variable": number, "name": paint})); + let id = create(&mut s, json!({})); + let before = s.doc().unwrap().clone(); + let depth = s.undo_depth(); + let e = s.execute("variable.bind", &json!({"id": id, "field": "fill", "variable": "surface"})).unwrap_err().to_string(); + assert!(e.contains("`fill` doesn't take a number variable"), "{e}"); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_depth(), depth); +} diff --git a/docs/mcp.md b/docs/mcp.md index 05dfd1e61..dc8e5719e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -281,7 +281,13 @@ Or in a project's `.mcp.json`: `null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables bound to what the style sets; a text style goes on text layers only. In one call, a style link (`fillStyle`, `textStyle`…) wins over values it sets; set a deviation in a later call, which - unlinks it. Names are trimmed per `/` folder part. `style.move {styles, before?, folder?}` + unlinks it. Names are trimmed per `/` folder part. A `fill` or `stroke` can also bind a variable: + `node.create {type: "rectangle", props: {fill: {"$var": "Theme/surface"}}}` creates and binds in + one undo step, and `node.set` takes the same wrapper. `$var` takes an exact variable id first, + else an unambiguous exact name or `Collection/name` (names can contain `/`); unknown or ambiguous + names are errors. The wrapper is applied after literal values and style links, and clears that + paint's style link even when the colour is unchanged. Only `fill` and `stroke` take variables in + props; use `variable.bind` for other fields. `style.move {styles, before?, folder?}` reorders (and with `folder`, renames into it), `style.duplicate {style}` adds " copy" right below, `style.renameFolder {folder, name}`. - **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba5..8d95700b5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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 (variables in paint props (M3, #1000); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Variables in props (M3, #1000): `node.create` and `node.set` take `{$var: variable id or name}` for `fill` and `stroke`, including unambiguous `Collection/name` lookups; bindings apply after literal values and style links and clear the paint's style link, with the same type checks, alpha and modes as `variable.bind`, in one undo step. Regression tests cover lookups, ordering, batch references, rollback and undo/redo. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | From a02c4abefc9fffc39fffb1aa548dc02bbc4da1bf Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Tue, 27 Oct 2026 12:21:44 +0100 Subject: [PATCH 04/30] M1: Alt pressed during a drag turns the move into a copy (#1385) Co-authored-by: Cursor --- crates/engine/src/canvas/snap.rs | 18 ++++++++ crates/engine/src/canvas/tools/drag.rs | 15 ++++++- crates/engine/src/duplicate_spacing_tests.rs | 45 ++++++++++++++++++++ docs/roadmap.md | 3 +- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index b68f1fe95..de1d2ff08 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -138,6 +138,24 @@ impl Targets { self.boxes.push(r); } + /// Take back what [`Targets::add_box`] added for `r`. + pub fn remove_box(&mut self, r: Rect) { + let take = |v: &mut Vec, s: Span| { + if let Some(i) = v.iter().rposition(|x| *x == s) { + v.remove(i); + } + }; + for x in [r.x0, r.center().x, r.x1] { + take(&mut self.xs, (x, r.y0, r.y1)); + } + for y in [r.y0, r.center().y, r.y1] { + take(&mut self.ys, (y, r.x0, r.x1)); + } + if let Some(i) = self.boxes.iter().rposition(|b| *b == r) { + self.boxes.remove(i); + } + } + /// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes, /// and a marker when a moving point lands on it. pub fn add_point(&mut self, p: Point) { diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 0d4066732..e9d35fef1 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -159,7 +159,20 @@ impl Session { } pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> { - let g = std::mem::take(&mut self.tool.gesture); + let mut g = std::mem::take(&mut self.tool.gesture); + // Alt makes a move a copy whenever it goes down, before the drag or during it, and letting + // go of it moves the original again; a copy snaps to the layers it came from too. + if let Gesture::Move { targets, duplicate, .. } = &mut g + && *duplicate != ev.alt + { + *duplicate = ev.alt; + if let Some(b) = targets.moving { + if ev.alt { targets.add_box(b) } else { targets.remove_box(b) } + } + if let Some(it) = self.ds_mut().ok().and_then(|ds| ds.interaction.as_mut()) { + it.label = if ev.alt { "Duplicate" } else { "Move" }.into(); + } + } let res = match &g { Gesture::None => return self.hover(ev), Gesture::Marquee { start, additive, base, within } => { diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs index 4c62179a7..817681b9a 100644 --- a/crates/engine/src/duplicate_spacing_tests.rs +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -169,6 +169,51 @@ fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) { s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap(); } +/// Drag from `from` to `to` in four steps, Alt held at step `k` when `alt(k)` (0: the press, 1–4: +/// the moves, 5: the release). +fn drag_alt_when(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: impl Fn(u8) -> bool) { + let ev = |k: u8, kind, (x, y): (f64, f64)| PointerEvent { alt: alt(k), ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&ev(0, PointerKind::Down, from)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(k, PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap(); + } + s.pointer(&ev(5, PointerKind::Up, to)).unwrap(); +} + +fn layers(s: &Session) -> usize { + s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len() +} + +fn last_step(s: &Session) -> String { + s.ds().unwrap().history.undo.last().map(|e| e.label.clone()).unwrap_or_default() +} + +#[test] +fn alt_pressed_during_a_drag_turns_the_move_into_a_copy() { + // #1385: as in other design apps, Alt counts whenever it goes down, not only at the press. + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + // Alt from the third step: the original goes back, a copy lands where the drag ends and snaps + // to the original (2 px below its top edge). + drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k >= 3); + let copy = s.selection()[0]; + assert_ne!(copy, a); + assert_eq!((at(&s, a), at(&s, copy)), ((0.0, 0.0), (200.0, 0.0))); + assert_eq!((layers(&s), last_step(&s).as_str()), (2, "Duplicate")); + assert!(s.undo().unwrap()); + assert_eq!((layers(&s), at(&s, a)), (1, (0.0, 0.0)), "one undo step"); + // Let go of Alt during the drag: no copy, the original moves (and no longer snaps to where it + // started, as only a copy does). + drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 3); + assert_eq!((layers(&s), s.selection(), at(&s, a)), (1, vec![a], (200.0, 2.0))); + assert_eq!(last_step(&s), "Move"); + // Down and up again during one drag: a copy, as Alt is held at the end. + s.undo().unwrap(); + drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 1 || k >= 4); + assert_eq!((layers(&s), at(&s, a)), (2, (0.0, 0.0))); +} + /// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests /// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done, /// and the step is measured from where the copy ended up, whichever way it got there. diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba5..24ffc3a04 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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 (Alt pressed during a drag duplicates (#1385); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Alt during a drag (M1, #1385): Alt (Option) turns a move into a copy whenever it goes down, before the drag or during it, and letting go of it during the drag moves the original again; the undo step is named for what the drag did, and only a copy snaps to the layers it came from. Regression test in the duplicate spacing tests. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | From 1b545e91b26afac749212ca250eafe02917c8f40 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Tue, 27 Oct 2026 12:45:45 +0100 Subject: [PATCH 05/30] M1: the release decides copy or move when Alt changed after the last move (#1385) Co-authored-by: Cursor --- crates/engine/src/canvas/tools/draw.rs | 4 ++++ crates/engine/src/duplicate_spacing_tests.rs | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index b594ae273..69c62ef9a 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -98,6 +98,10 @@ impl Session { } pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> { + // Alt went down or up since the last move: the drop is a copy or a move as the release has it. + if matches!(&self.tool.gesture, Gesture::Move { moved: true, duplicate, .. } if *duplicate != ev.alt) { + self.drag(ev)?; + } let g = std::mem::take(&mut self.tool.gesture); self.tool.overlay.marquee = None; self.tool.overlay.guides.clear(); diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs index 817681b9a..c46ab05c8 100644 --- a/crates/engine/src/duplicate_spacing_tests.rs +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -212,6 +212,13 @@ fn alt_pressed_during_a_drag_turns_the_move_into_a_copy() { s.undo().unwrap(); drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 1 || k >= 4); assert_eq!((layers(&s), at(&s, a)), (2, (0.0, 0.0))); + // Alt changed only by the release (no move in between): the release decides. + s.undo().unwrap(); + drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 5); + assert_eq!((layers(&s), at(&s, a), last_step(&s).as_str()), (2, (0.0, 0.0), "Duplicate"), "Alt down at the release: a copy"); + s.undo().unwrap(); + drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 5); + assert_eq!((layers(&s), at(&s, a), last_step(&s).as_str()), (1, (200.0, 2.0), "Move"), "Alt up at the release: a move"); } /// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests From 54eced7ffb12db0278532e5b801df93e9f2a81c2 Mon Sep 17 00:00:00 2001 From: Guillermo Bueno Date: Tue, 27 Oct 2026 20:48:08 +0100 Subject: [PATCH 06/30] M6: address offline Penpot review regressions --- ROADMAP.md | 2 +- crates/format/src/penpot.rs | 21 +++++++++++++++++++-- crates/format/src/penpot/prototype.rs | 21 ++++++++++++++++++++- docs/file-format-parity.md | 3 ++- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d5b943f76..a66083087 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,6 @@ start with [`docs/gaps.md`](docs/gaps.md). | | Value | Kind | |---|---|---| -| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. | | Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | | Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | @@ -352,6 +351,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 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 | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity 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 | | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 6ecab4a79..d5154f0dc 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -1384,8 +1384,9 @@ fn bind_applied_tokens(doc: &mut Document, active_modes: &BTreeMap> = BTreeMap::new(); for var in &doc.variables.variables { let Some(raw) = var.id.strip_prefix("penpot:tokens:files/") else { continue }; - let Some((file_id, _)) = raw.split_once(':') else { continue }; - let Some((_, short_name)) = var.name.split_once('/') else { continue }; + let Some((file_id, path)) = raw.split_once(':') else { continue }; + // Legacy ids include the set; themed ids contain just the token path. + let short_name = var.id.strip_prefix(&format!("{}:", var.collection)).unwrap_or(path); for token_name in [short_name, var.name.as_str()] { index.entry((file_id.to_owned(), token_name.to_owned())).or_default().push(var.id.clone()); } @@ -6022,6 +6023,22 @@ mod tests { assert!(doc.validate().is_empty()); } + #[test] + fn bindings_accept_short_names_in_nested_token_sets() { + let tokens = serde_json::json!({"sets":{"Mode/Light":{"tokens":{ + "palette/bg":{"type":"color","value":"#336699"}, + "width":{"type":"number","value":120} + }}}}); + let layer = serde_json::json!({"id":"cccccccc-cccc-cccc-cccc-cccccccccccc", "name":"Nested token set", "type":"rect", + "width":120,"height":60,"fills":[{"fillColor":"#336699"}], + "appliedTokens":{"fill":"palette/bg","width":"Mode/Light/width"}}); + let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, Some(layer))).unwrap(); + let node = doc.nodes().find(|n| n.name == "Nested token set").unwrap(); + assert_eq!(node.bound.get("fills/0/color"), doc.variables.get("Mode/Light/palette/bg").map(|v| &v.id)); + assert_eq!(node.bound.get("width"), doc.variables.get("Mode/Light/width").map(|v| &v.id)); + assert_eq!(node.bound.len(), 2, "{warnings:?}"); + } + #[test] fn penpot_v14_refuses_ambiguous_sets_and_different_resolved_values() { let id = "cccccccc-cccc-cccc-cccc-cccccccccccc"; diff --git a/crates/format/src/penpot/prototype.rs b/crates/format/src/penpot/prototype.rs index 5c48162c3..44f8b4194 100644 --- a/crates/format/src/penpot/prototype.rs +++ b/crates/format/src/penpot/prototype.rs @@ -334,7 +334,7 @@ pub(super) fn page_flows(doc: &Document, flows: &Value, by_penpot_id: &BTreeMap< let Some(start) = flow.get("startingFrame").and_then(Value::as_str) else { continue }; let Some(&frame) = by_penpot_id.get(start) else { // A flow whose starting frame is not in the file cannot start anything. - warnings.push("A Penpot flow names a starting frame that is not in the file; it was skipped".into()); + warn(warnings, "A Penpot flow names a starting frame that is not in the file; it was skipped".into()); continue; }; // The player shows the top-level frame a starting frame sits in. @@ -344,3 +344,22 @@ pub(super) fn page_flows(doc: &Document, flows: &Value, by_penpot_id: &BTreeMap< } out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_flow_warnings_are_deduplicated_and_bounded_across_pages() { + let doc = Document::new("Missing flows"); + let flows = serde_json::json!({"a":{"startingFrame":"missing"},"b":{"startingFrame":"missing"}}); + let mut warnings = Vec::new(); + for _ in 0..110 { + assert!(page_flows(&doc, &flows, &BTreeMap::new(), &mut warnings).is_empty()); + } + assert_eq!(warnings.len(), 1); + warnings = (0..100).map(|i| format!("Prior warning {i}")).collect(); + page_flows(&doc, &flows, &BTreeMap::new(), &mut warnings); + assert_eq!(warnings.len(), 100); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 86969bf5e..82fbfdc06 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,6 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. | | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | @@ -183,7 +182,9 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | patch | M6: merge main and isolate offline Penpot support; preserve conversion warnings, retain nested token-set bindings, bound missing-flow warnings and keep export regressions registered. Numbers unchanged. | | 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged | +| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. | | 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged | | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged | | 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before | From 48a39ca7aa1c75c2adef3eb4b2e5cb032a87739a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 11:32:49 +0700 Subject: [PATCH 07/30] #1409: test that a continued batch reports only the live events of the steps that stayed --- .../engine/src/batch_continue_events_tests.rs | 33 +++++++++++++++++++ crates/engine/src/lib.rs | 2 ++ 2 files changed, 35 insertions(+) create mode 100644 crates/engine/src/batch_continue_events_tests.rs diff --git a/crates/engine/src/batch_continue_events_tests.rs b/crates/engine/src/batch_continue_events_tests.rs new file mode 100644 index 000000000..280f9a696 --- /dev/null +++ b/crates/engine/src/batch_continue_events_tests.rs @@ -0,0 +1,33 @@ +//! A batch that goes on after a failed step (`onError: "continue"`) reports the live events of the +//! steps that stayed, not of the ones rolled back. + +use std::time::Duration; + +use serde_json::json; + +use crate::Session; + +#[test] +fn a_continued_batch_reports_only_the_events_of_steps_that_stayed() { + let mut s = Session::new(); + let before = s.events.latest(); + let r = s + .execute( + "command.batch", + &json!({"onError": "continue", "commands": [ + {"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Stays"}}, + {"command": "command.batch", "params": {"commands": [ + {"command": "comment.add", "params": {"x": 20, "y": 20, "text": "Rolled back"}}, + {"command": "node.set", "params": {"ids": [999_999], "props": {"name": "x"}}} + ]}}, + {"command": "comment.add", "params": {"x": 30, "y": 30, "text": "Stays too"}} + ]}), + ) + .unwrap(); + let ok: Vec = r.as_array().unwrap().iter().map(|o| o["ok"].as_bool().unwrap()).collect(); + assert_eq!(ok, [true, false, true]); + let texts: Vec = s.doc().unwrap().comments.iter().map(|c| c.first.text.clone()).collect(); + assert_eq!(texts, ["Stays", "Stays too"]); + let events = s.events.poll(before, &["comments".into()], 10, Duration::ZERO).events; + assert_eq!(events.iter().map(|e| e.kind.as_str()).collect::>(), ["comment.added", "comment.added"]); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0db81fd1e..3797f904d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -179,6 +179,8 @@ mod arc_handles_tests; #[cfg(test)] mod autosave_tests; #[cfg(test)] +mod batch_continue_events_tests; +#[cfg(test)] mod batch_rollback_tests; #[cfg(test)] mod batch_step_ref_tests; From 0265f066b39dee7c25f141ef26dcb2f44143933f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 11:40:21 +0700 Subject: [PATCH 08/30] #1409: split a long batch test for the cognitive complexity lint --- crates/engine/src/batch_step_ref_tests.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index f5ce3a831..17d9fc725 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -371,6 +371,11 @@ fn a_continue_batch_keeps_successful_steps_as_one_undo_entry() { let error = results[1]["error"].as_str().unwrap(); assert!(error.contains("step 1:") && error.contains("is not text"), "{error}"); assert!(results[1].get("result").is_none()); + undo_and_redo_the_continued_batch(&mut s, frame, text, &name, undo); +} + +/// The successful steps of the batch above undo and redo as one step. +fn undo_and_redo_the_continued_batch(s: &mut Session, frame: figcraft_doc::NodeId, text: figcraft_doc::NodeId, name: &str, undo: usize) { assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo")); assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro"); assert_eq!(s.undo_depth(), (undo + 1, 0)); From 4b743fee95bee02406fc6fc39f6c9fb984754236 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 12:54:49 +0700 Subject: [PATCH 09/30] #1143: split add_layer (an importer context instead of 19 arguments and its allow), import_themed_tokens and the exporter's shape for cognitive_complexity --- crates/format/src/penpot.rs | 1087 ++++++++++++++-------------- crates/format/src/penpot/export.rs | 192 ++--- 2 files changed, 658 insertions(+), 621 deletions(-) diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index f7ed60d53..554c23b1c 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -550,55 +550,31 @@ pub fn import(bytes: &[u8]) -> Result<(Document, Vec), FormatError> { let svg_defs = svg_gradients(&shapes); let roots: Vec = list(root, "shapes").filter_map(Value::as_str).map(str::to_owned).collect(); let mut seen = BTreeSet::new(); + let mut layers = LayerImport { + z: &mut z, + base: &base, + shapes: &shapes, + children_by_parent: &children_by_parent, + svg_defs: &svg_defs, + masters: &masters, + doc: &mut doc, + images: &mut image_keys, + image_bytes: &mut image_bytes, + inflate_budget: &mut inflate_budget, + svg_bytes: &mut svg_bytes, + warnings: &mut warnings, + seen: &mut seen, + count: &mut layer_count, + }; for id in roots { - add_layer( - &mut z, - &base, - &shapes, - &children_by_parent, - &svg_defs, - &masters, - &id, - page, - Affine::IDENTITY, - None, - &mut doc, - &mut image_keys, - &mut image_bytes, - &mut inflate_budget, - &mut svg_bytes, - &mut warnings, - &mut seen, - 0, - &mut layer_count, - )?; + layers.add_layer(&id, page, Affine::IDENTITY, None, 0)?; } // Recover root children not listed in the export's stacking-order array. if let Some(unlinked) = children_by_parent.get(ROOT_ID) { for id in unlinked { - if !seen.contains(id) { - warn(&mut warnings, "Penpot page omitted a top-level child from its stacking list; recovered it by parentId".into()); - add_layer( - &mut z, - &base, - &shapes, - &children_by_parent, - &svg_defs, - &masters, - id, - page, - Affine::IDENTITY, - None, - &mut doc, - &mut image_keys, - &mut image_bytes, - &mut inflate_budget, - &mut svg_bytes, - &mut warnings, - &mut seen, - 0, - &mut layer_count, - )?; + if !layers.seen.contains(id) { + warn(layers.warnings, "Penpot page omitted a top-level child from its stacking list; recovered it by parentId".into()); + layers.add_layer(id, page, Affine::IDENTITY, None, 0)?; } } } @@ -608,69 +584,29 @@ pub fn import(bytes: &[u8]) -> Result<(Document, Vec), FormatError> { // First recover the true orphan roots, preserving their descendants // with the original global Penpot coordinates. for (id, shape) in &shapes { - if id == ROOT_ID || seen.contains(id) { + if id == ROOT_ID || layers.seen.contains(id) { continue; } let parent_id = string(shape, "parentId"); let parent_exists = parent_id.is_some_and(|pid| pid != id.as_str() && shapes.contains_key(pid)); if !parent_exists { warn( - &mut warnings, + layers.warnings, format!( "Penpot orphan layer '{}' has no reachable parent; attached to page", string(shape, "name").unwrap_or(id).chars().take(80).collect::() ), ); - add_layer( - &mut z, - &base, - &shapes, - &children_by_parent, - &svg_defs, - &masters, - id, - page, - Affine::IDENTITY, - None, - &mut doc, - &mut image_keys, - &mut image_bytes, - &mut inflate_budget, - &mut svg_bytes, - &mut warnings, - &mut seen, - 0, - &mut layer_count, - )?; + layers.add_layer(id, page, Affine::IDENTITY, None, 0)?; } } // Remaining disconnected subgraphs can be caused by cycles or a // parent chain deeper than MAX_DEPTH. Import one deterministic root // for each instead of dropping editable content without warning. for id in shapes.keys() { - if id != ROOT_ID && !seen.contains(id) { - warn(&mut warnings, format!("Penpot disconnected/cyclic layer graph at {id}; recovered as a page layer")); - add_layer( - &mut z, - &base, - &shapes, - &children_by_parent, - &svg_defs, - &masters, - id, - page, - Affine::IDENTITY, - None, - &mut doc, - &mut image_keys, - &mut image_bytes, - &mut inflate_budget, - &mut svg_bytes, - &mut warnings, - &mut seen, - 0, - &mut layer_count, - )?; + if id != ROOT_ID && !layers.seen.contains(id) { + warn(layers.warnings, format!("Penpot disconnected/cyclic layer graph at {id}; recovered as a page layer")); + layers.add_layer(id, page, Affine::IDENTITY, None, 0)?; } } } @@ -2068,6 +2004,119 @@ fn indexed_token<'a>( }) } +/// The themes of a token file by group, each with the token sets it turns on (of those in `tokens`). +fn token_theme_groups<'a>(value: &'a Value, tokens: &BTreeMap<&str, BTreeMap>) -> Vec<(String, Vec>)> { + let mut groups: Vec<(String, Vec)> = Vec::new(); + for theme in list(value, "$themes").take(256) { + let Some(name) = string(theme, "name") else { continue }; + let group = string(theme, "group").filter(|g| !g.trim().is_empty()).unwrap_or("Themes").to_owned(); + let sets = theme + .get("selectedTokenSets") + .and_then(Value::as_object) + .map(|m| { + m.iter() + .filter(|(set, state)| state.as_str().is_some_and(|s| s == "enabled" || s == "source") && tokens.contains_key(set.as_str())) + .map(|(set, _)| set.as_str()) + .collect() + }) + .unwrap_or_default(); + let id = string(theme, "id").unwrap_or(name).chars().take(80).collect(); + match groups.iter_mut().find(|(g, _)| *g == group) { + Some((_, themes)) => themes.push(TokenTheme { id, name, sets }), + None => groups.push((group, vec![TokenTheme { id, name, sets }])), + } + } + groups +} + +/// Drops the resolved tokens that alias a variable which isn't being made, until none does; how +/// many were dropped. +fn drop_dangling_aliases( + resolved: &mut BTreeMap<&str, (usize, VariableType, BTreeMap)>, + var_id: &dyn Fn(&str) -> String, +) -> usize { + let mut dropped = 0; + loop { + let ids: BTreeSet = resolved.keys().map(|p| var_id(p)).collect(); + let dangling: Vec<&str> = resolved + .iter() + .filter(|(_, (_, _, values))| values.values().any(|v| matches!(v, VariableValue::Alias(target) if !ids.contains(target)))) + .map(|(path, _)| *path) + .collect(); + if dangling.is_empty() { + return dropped; + } + dropped += dangling.len(); + for path in dangling { + resolved.remove(path); + } + } +} + +/// Moves a base token whose expression refers to a themed token into that token's collection, so +/// it can follow the theme, until nothing moves (64 rounds at most); false when the lookup budget +/// ran out. +fn move_referencing_tokens( + owner: &mut BTreeMap, + base_enabled: &BTreeSet, + path_index: &BTreeMap<&str, Vec<(usize, &Value)>>, + resolution_budget: &mut TokenResolutionBudget, +) -> bool { + for _ in 0..owner.len().min(64) { + let moves: Vec<(String, usize)> = owner + .iter() + .filter(|(_, index)| **index == 0) + .filter_map(|(path, _)| { + resolution_budget.spend(1)?; + let token = indexed_token(path, base_enabled, path_index, resolution_budget)?; + if token_alias(token).is_some() { + return None; + } + let text = token.get("$value").or_else(|| token.get("value"))?.as_str()?; + resolution_budget.spend(text.len())?; + text.split('{') + .skip(1) + .filter_map(|part| part.split_once('}').map(|(r, _)| r.trim())) + .find_map(|r| owner.get(r).copied().filter(|i| *i != 0)) + .map(|i| (path.clone(), i)) + }) + .collect(); + if resolution_budget.remaining == 0 { + return false; + } + if moves.is_empty() { + break; + } + owner.extend(moves); + } + true +} + +/// Each token path's variable type, from its newest set that gives one; `None` when the lookup +/// budget ran out. +fn token_kinds<'o>( + owner: &'o BTreeMap, + path_index: &BTreeMap<&str, Vec<(usize, &Value)>>, + resolution_budget: &mut TokenResolutionBudget, +) -> Option> { + let mut kinds = BTreeMap::new(); + for path in owner.keys() { + let kind = path_index.get(path.as_str()).and_then(|occurrences| { + occurrences.iter().rev().find_map(|(_, token)| { + resolution_budget.spend(1)?; + token_kind(token) + }) + }); + if resolution_budget.remaining == 0 { + return None; + } + if let Some(kind) = kind { + kinds.insert(path.as_str(), kind); + } + } + Some(kinds) +} + /// Import token sets and theme groups as variables, collections and modes. fn import_themed_tokens( value: &Value, @@ -2100,26 +2149,7 @@ fn import_themed_tokens( path_index.entry(path.as_str()).or_default().push((set_id, *value)); } } - let mut groups: Vec<(String, Vec)> = Vec::new(); - for theme in list(value, "$themes").take(256) { - let Some(name) = string(theme, "name") else { continue }; - let group = string(theme, "group").filter(|g| !g.trim().is_empty()).unwrap_or("Themes").to_owned(); - let sets = theme - .get("selectedTokenSets") - .and_then(Value::as_object) - .map(|m| { - m.iter() - .filter(|(set, state)| state.as_str().is_some_and(|s| s == "enabled" || s == "source") && tokens.contains_key(set.as_str())) - .map(|(set, _)| set.as_str()) - .collect() - }) - .unwrap_or_default(); - let id = string(theme, "id").unwrap_or(name).chars().take(80).collect(); - match groups.iter_mut().find(|(g, _)| *g == group) { - Some((_, themes)) => themes.push(TokenTheme { id, name, sets }), - None => groups.push((group, vec![TokenTheme { id, name, sets }])), - } - } + let groups = token_theme_groups(value, &tokens); let (switching, constant): (Vec<_>, Vec<_>) = groups.into_iter().partition(|(_, themes)| themes.len() > 1); let themed_sets: BTreeSet<&str> = switching.iter().flat_map(|(_, t)| t.iter().flat_map(|t| t.sets.iter().copied())).collect(); let active_sets: Vec<&str> = value.get("$metadata").map(|m| list(m, "activeSets").filter_map(Value::as_str).collect()).unwrap_or_default(); @@ -2175,51 +2205,15 @@ fn import_themed_tokens( } } } - for _ in 0..owner.len().min(64) { - let moves: Vec<(String, usize)> = owner - .iter() - .filter(|(_, index)| **index == 0) - .filter_map(|(path, _)| { - resolution_budget.spend(1)?; - let token = indexed_token(path, &base_enabled, &path_index, &mut resolution_budget)?; - if token_alias(token).is_some() { - return None; - } - let text = token.get("$value").or_else(|| token.get("value"))?.as_str()?; - resolution_budget.spend(text.len())?; - text.split('{') - .skip(1) - .filter_map(|part| part.split_once('}').map(|(r, _)| r.trim())) - .find_map(|r| owner.get(r).copied().filter(|i| *i != 0)) - .map(|i| (path.clone(), i)) - }) - .collect(); - if resolution_budget.remaining == 0 { - warn(warnings, "Penpot token lookup work budget exhausted; skipped themed tokens".into()); - return false; - } - if moves.is_empty() { - break; - } - owner.extend(moves); + if !move_referencing_tokens(&mut owner, &base_enabled, &path_index, &mut resolution_budget) { + warn(warnings, "Penpot token lookup work budget exhausted; skipped themed tokens".into()); + return false; } let var_id = |path: &str| format!("penpot:tokens:{base}:{path}"); - let mut kinds = BTreeMap::new(); - for path in owner.keys() { - let kind = path_index.get(path.as_str()).and_then(|occurrences| { - occurrences.iter().rev().find_map(|(_, token)| { - resolution_budget.spend(1)?; - token_kind(token) - }) - }); - if resolution_budget.remaining == 0 { - warn(warnings, "Penpot token lookup work budget exhausted; skipped themed tokens".into()); - return false; - } - if let Some(kind) = kind { - kinds.insert(path.as_str(), kind); - } - } + let Some(kinds) = token_kinds(&owner, &path_index, &mut resolution_budget) else { + warn(warnings, "Penpot token lookup work budget exhausted; skipped themed tokens".into()); + return false; + }; let mut resolved: BTreeMap<&str, (usize, VariableType, BTreeMap)> = BTreeMap::new(); let mut skipped = 0usize; for (path, &index) in &owner { @@ -2259,21 +2253,7 @@ fn import_themed_tokens( } resolved.insert(path.as_str(), (index, kind, values)); } - loop { - let ids: BTreeSet = resolved.keys().map(|p| var_id(p)).collect(); - let dangling: Vec<&str> = resolved - .iter() - .filter(|(_, (_, _, values))| values.values().any(|v| matches!(v, VariableValue::Alias(target) if !ids.contains(target)))) - .map(|(path, _)| *path) - .collect(); - if dangling.is_empty() { - break; - } - skipped += dangling.len(); - for path in dangling { - resolved.remove(path); - } - } + skipped += drop_dangling_aliases(&mut resolved, &var_id); let mut converted = 0usize; let mut used_collections = BTreeSet::new(); for (path, (index, kind, values)) in resolved { @@ -2960,55 +2940,37 @@ fn svg_paint_ref(text: &str) -> Option<&str> { (!id.is_empty() && id.len() <= 200).then_some(id) } -/// Convert one shape and its children, interpreting Penpot's absolute x/y relative to the parent. -#[allow(clippy::too_many_arguments)] -/// Import one shape and its descendants within hierarchy and asset budgets. -fn add_layer( - z: &mut ZipArchive>, - base: &str, - shapes: &BTreeMap, - children_by_parent: &BTreeMap>, - svg_defs: &BTreeMap, - masters: &BTreeMap, - id: &str, - parent: NodeId, - parent_abs: Affine, - cell: Option<&Value>, - doc: &mut Document, - images: &mut BTreeMap, - image_bytes: &mut usize, - inflate_budget: &mut InflateBudget, - svg_bytes: &mut usize, +/// What importing a page's layers shares from layer to layer: the archive and the page's shapes, +/// and the document, budgets and warnings they add to. +struct LayerImport<'a, 'b> { + z: &'a mut ZipArchive>, + base: &'a str, + shapes: &'a BTreeMap, + children_by_parent: &'a BTreeMap>, + svg_defs: &'a BTreeMap, + masters: &'a BTreeMap, + doc: &'a mut Document, + images: &'a mut BTreeMap, + image_bytes: &'a mut usize, + inflate_budget: &'a mut InflateBudget, + svg_bytes: &'a mut usize, + warnings: &'a mut Vec, + seen: &'a mut BTreeSet, + count: &'a mut usize, +} + +/// A Penpot layer's kind, with the box and outline of a decoded path (in the layer's own space). +fn layer_kind( + v: &Value, + typ: &str, + is_master: bool, + (x, y, w, h): (f64, f64, f64, f64), warnings: &mut Vec, - seen: &mut BTreeSet, - depth: usize, - count: &mut usize, -) -> Result<(), FormatError> { - if depth >= MAX_DEPTH { - warn(warnings, "Penpot layer nesting deeper than 128 was omitted".into()); - return Ok(()); - } - if !seen.insert(id.to_owned()) { - return Ok(()); - } - let Some(v) = shapes.get(id) else { - warn(warnings, format!("Missing layer {id}")); - return Ok(()); - }; - *count += 1; - if *count > MAX_LAYERS { - return Err(corrupt("too many layers")); - } - let name = string(v, "name").unwrap_or("Layer").chars().take(512).collect::(); - let x = geometry(v, "x", 0.0); - let y = geometry(v, "y", 0.0); - let w = geometry(v, "width", 1.0).clamp(0.01, 1_000_000.0); - let h = geometry(v, "height", 1.0).clamp(0.01, 1_000_000.0); - let typ = string(v, "type").unwrap_or(""); +) -> (NodeKind, Option, Option) { let mut path_box = None; let mut vector_source = None; let kind = match typ { - "frame" if masters.contains_key(id) => NodeKind::Component { description: String::new(), props: BTreeMap::new() }, + "frame" if is_master => NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "frame" if v.get("isVariantContainer").and_then(Value::as_bool) == Some(true) => NodeKind::ComponentSet { description: String::new() }, "frame" => NodeKind::Frame, "group" => NodeKind::Group, @@ -3021,7 +2983,7 @@ fn add_layer( }, }, "rect" | "image" => NodeKind::Rectangle, - // Expanded in the engine using the same native importer as File → Import SVG. + // Expanded in the engine using the same native importer as File → Import SVG. // This parsing crate must not depend on the higher-level SVG/render crates. "svg-raw" => NodeKind::Frame, "circle" | "ellipse" => NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, @@ -3052,27 +3014,221 @@ fn add_layer( NodeKind::Frame } }; - let abs = if kind.bounds_follow_children() { Affine::translate((x, y)) } else { shape_matrix(v, x, y, w, h) }; - let (abs, w, h) = match path_box { - Some(b) => (abs * Affine::translate(b.origin().to_vec2()), b.width().max(0.01), b.height().max(0.01)), - None => (abs, w, h), + (kind, path_box, vector_source) +} + +/// A layer's stroke settings from its first Penpot stroke. +fn apply_stroke(node: &mut figcraft_doc::Node, stroke: &Value) { + node.stroke.weight = number(stroke, "strokeWidth", 1.0).clamp(0.0, 10000.0); + node.stroke.align = match string(stroke, "strokeAlignment") { + Some("center") => StrokeAlign::Center, + Some("outer") | Some("outside") => StrokeAlign::Outside, + _ => StrokeAlign::Inside, }; - let created = doc.create(parent, kind, &name, 0.0, 0.0, w, h).map_err(|e| corrupt(format!("layer '{name}': {e}")))?; - let mut fills = paints(z, base, v, "fills", doc, images, image_bytes, inflate_budget, warnings); - // Some Penpot versions keep a stand-alone image's reference in `metadata`, not `fills`. - if typ == "image" - && let Some(metadata) = v.get("metadata") - { - let mut stretched = metadata.clone(); - if let Some(obj) = stretched.as_object_mut() { - obj.insert("keepAspectRatio".into(), Value::Bool(false)); - } - let mut all = vec![serde_json::json!({"fillImage": stretched, "fillOpacity": 1.0})]; - all.extend(list(v, "fills").cloned()); - let temporary = serde_json::json!({ "fills": all }); - fills = paints(z, base, &temporary, "fills", doc, images, image_bytes, inflate_budget, warnings); + node.stroke.join = match string(stroke, "strokeJoin") { + Some("round") => StrokeJoin::Round, + Some("bevel") => StrokeJoin::Bevel, + _ => StrokeJoin::Miter, + }; + let cap = match string(stroke, "strokeCapStart") { + Some("round") => StrokeCap::Round, + Some("square") => StrokeCap::Square, + Some("line-arrow") => StrokeCap::LineArrow, + Some("triangle-arrow") => StrokeCap::TriangleArrow, + _ => StrokeCap::None, + }; + node.stroke.cap_start = cap; + node.stroke.cap_end = match string(stroke, "strokeCapEnd") { + Some("round") => StrokeCap::Round, + Some("square") => StrokeCap::Square, + Some("line-arrow") => StrokeCap::LineArrow, + Some("triangle-arrow") => StrokeCap::TriangleArrow, + _ => cap, + }; + let w = node.stroke.weight; + node.stroke.dashes = match string(stroke, "strokeStyle") { + Some("dotted") => vec![0.0, w + 5.0], + Some("dashed") => vec![w + 10.0, w + 10.0], + Some("mixed") => vec![w + 5.0, w + 5.0, w + 1.0, w + 5.0], + _ => Vec::new(), + }; + if string(stroke, "strokeStyle") == Some("dotted") { + let dot = |cap: StrokeCap| if matches!(cap, StrokeCap::LineArrow | StrokeCap::TriangleArrow) { cap } else { StrokeCap::Round }; + (node.stroke.cap_start, node.stroke.cap_end) = (dot(node.stroke.cap_start), dot(node.stroke.cap_end)); } - if matches!(typ, "rect" | "circle" | "path" | "bool") { +} + +/// A layer's visibility, lock, opacity, blend, clip, mask and constraints. +fn apply_appearance(node: &mut figcraft_doc::Node, v: &Value) { + node.visible = v.get("hidden").and_then(Value::as_bool) != Some(true); + node.locked = v.get("blocked").and_then(Value::as_bool) == Some(true); + node.opacity = number(v, "opacity", 1.0).clamp(0.0, 1.0) as f32; + node.blend = string(v, "blendMode").and_then(BlendMode::from_name).unwrap_or(BlendMode::Normal); + node.clip = string(v, "type") == Some("frame") && v.get("showContent").and_then(Value::as_bool) != Some(true); + node.is_mask = v.get("mask").and_then(Value::as_bool) == Some(true); + node.mask_type = match string(v, "maskType") { + Some("vector") => MaskType::Vector, + Some("luminance") => MaskType::Luminance, + _ => MaskType::Alpha, + }; + node.constraints.horizontal = match string(v, "constraintsH") { + Some("right") => HConstraint::Right, + Some("leftright") | Some("left-right") => HConstraint::LeftRight, + Some("center") => HConstraint::Center, + Some("scale") => HConstraint::Scale, + _ => HConstraint::Left, + }; + node.constraints.vertical = match string(v, "constraintsV") { + Some("bottom") => VConstraint::Bottom, + Some("topbottom") | Some("top-bottom") => VConstraint::TopBottom, + Some("center") => VConstraint::Center, + Some("scale") => VConstraint::Scale, + _ => VConstraint::Top, + }; +} + +/// An `svg-raw` layer's source tree and markup, kept for the engine to expand (bounded by +/// `svg_bytes`, the archive's raw SVG budget). +fn keep_raw_svg(node: &mut figcraft_doc::Node, v: &Value, name: &str, svg_bytes: &mut usize, warnings: &mut Vec) { + // If future SVG support improves, keep the original tree (when small) + // alongside the reconstructed markup for faithful re-import. + preserve_source(node, v, &["content", "svgAttrs", "svg-attrs"]); + if let Some(xml) = raw_svg_xml(v) { + // Avoid unbounded retained markup in huge archives or projects. + if xml.len() <= (8 * 1024 * 1024usize).saturating_sub(*svg_bytes) { + *svg_bytes += xml.len(); + node.plugin_data.insert("penpot.svgRawXml".into(), xml); + node.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into()); + } else { + warn(warnings, format!("SVG layer '{name}' exceeds the 8 MiB raw SVG import budget; kept as frame")); + } + } else { + warn(warnings, format!("SVG layer '{name}' has no safely readable SVG markup; kept as frame")); + } +} + +/// A text layer's font references, kept as the source names them. +fn keep_font_refs(node: &mut figcraft_doc::Node, v: &Value) { + let references = referenced_google_fonts(v); + if !references.is_empty() { + node.plugin_data.insert("penpot.googleFontRefs".into(), serde_json::to_string(&references).unwrap_or_default()); + } + // A `custom-*` identifier refers to Penpot's separately managed + // font service. It is not a URL and does not imply that the binary + // is contained in this `.penpot` archive. Preserve the exact id so + // the desktop importer can report or resolve it without guessing. + let custom = referenced_custom_fonts(v); + if !custom.is_empty() { + node.plugin_data.insert("penpot.customFontRefs".into(), serde_json::to_string(&custom).unwrap_or_default()); + } +} + +/// The Penpot source fields a layer keeps for a faithful re-export (see [`preserve_source`]). +const PRESERVED_LAYER_KEYS: &[&str] = &[ + "layout", + "layoutMode", + "layoutFlexDir", + "layoutWrapType", + "layoutGap", + "layoutPadding", + "layoutJustifyContent", + "layoutAlignItems", + "layoutAlignContent", + "layoutGridColumns", + "layoutGridRows", + "layoutGridCells", + "layoutItemMargin", + "layoutItemAlignSelf", + "layoutItemAbsolute", + "layoutItemHSizing", + "layoutItemVSizing", + "touched", + "appliedTokens", + "interactions", + "variantProperties", + "pluginData", + "grids", + "guides", + "maskType", + "svgAttrs", + "svgViewbox", + "svgDefs", + "layoutGapType", + "layoutPaddingType", + "layoutJustifyItems", + "layoutGridDir", + "componentRoot", + "mainInstance", + "layoutItemZIndex", + "exports", + "hideInViewer", + "strokes", +]; + +/// The Penpot ids a layer keeps: its own, its file's and its component's. +fn keep_ids(node: &mut figcraft_doc::Node, v: &Value, id: &str, base: &str, master: Option<&String>) { + node.plugin_data.insert("penpot.id".into(), id.to_owned()); + if let Some(file_id) = base.strip_prefix("files/") { + node.plugin_data.insert("penpot.fileId".into(), file_id.to_owned()); + } + if let Some(component) = master { + node.plugin_data.insert("penpot.componentMasterId".into(), component.clone()); + } + if v.get("componentId").is_some() && master.is_none() { + node.plugin_data.insert("penpot.componentLinkStatus".into(), "not-linked".into()); + } +} + +/// The Penpot source fields a layer keeps: the layout and other settings FigCraft can't hold, and +/// its component references. +fn keep_source(node: &mut figcraft_doc::Node, v: &Value, typ: &str) { + // Mark unsupported properties on their exact source nodes. Layout changes may + // otherwise silently discard these Penpot-specific rules on subsequent edits. + if string(v, "layoutItemAlignSelf") + .is_some_and(|a| !matches!(a, "auto" | "start" | "flex-start" | "center" | "end" | "flex-end" | "stretch" | "baseline")) + { + node.plugin_data.insert("penpot.unsupported.alignSelf".into(), "true".into()); + } + preserve_source(node, v, PRESERVED_LAYER_KEYS); + if typ == "text" { + preserve_source_within(node, v, &["content", "positionData"], 256 * 1024); + } + for key in ["componentId", "componentFile", "shapeRef", "variantId"] { + if let Some(value) = string(v, key) + && value.len() <= 1024 + { + node.plugin_data.insert(format!("penpot.{key}"), value.to_owned()); + } + } +} + +/// A text layer's fills: a paint all its runs share moves to the layer, and a text layer with no +/// fill gets black. +fn settle_text_fills(node: &mut figcraft_doc::Node, typ: &str) { + if let NodeKind::Text(text) = &mut node.kind + && let Some(paints) = uniform_text_fills(text) + { + node.fills = paints; + text.style.fills = None; + for run in &mut text.runs { + run.style.fills = None; + } + } + if typ == "text" && node.fills.is_empty() { + node.fills.push(Paint::solid(Color::BLACK)); + } +} + +impl LayerImport<'_, '_> { + /// Penpot paints of `v`'s `key` (fills or strokes). + fn layer_paints(&mut self, v: &Value, key: &str) -> Vec { + paints(self.z, self.base, v, key, self.doc, self.images, self.image_bytes, self.inflate_budget, self.warnings) + } + + /// A shape's fills from its SVG attributes (a style or attribute fill, or one inherited from its + /// SVG ancestors), and a paint-server reference resolved to a gradient, when it has no fills of + /// its own. + fn svg_fills(&mut self, v: &Value, typ: &str, abs: Affine, (x, y, w, h): (f64, f64, f64, f64), mut fills: Vec) -> Vec { let svg = v.get("svgAttrs"); let style_fill = svg.and_then(|a| a.get("style")).and_then(|s| string(s, "fill")); let attr_fill = svg.and_then(|a| string(a, "fill")); @@ -3082,330 +3238,193 @@ fn add_layer( None if list(v, "fills").next().is_some() => None, None => Some(match attr_fill { Some(fill) => serde_json::json!({"fills": [{"fillColor": fill}]}), - None => inherited_fill(shapes, v), + None => inherited_fill(self.shapes, v), }), }; if let Some(source) = source { - fills = paints(z, base, &source, "fills", doc, images, image_bytes, inflate_budget, warnings); + fills = self.layer_paints(&source, "fills"); } - if let Some(reference) = style_fill.or(attr_fill).and_then(svg_paint_ref) - && fills.is_empty() - { - let mut local = BTreeMap::new(); - if let Some(defs) = v.get("svgDefs") { - svg_gradient_nodes(defs, None, 0, &mut local); - } - let resolved = if local.contains_key(reference) { - let to_local = if typ == "path" { abs.inverse() } else { Affine::translate((-x, -y)) }; - let to_unit = svg_space_matrix(v).map(|m| Affine::scale_non_uniform(1.0 / w, 1.0 / h) * to_local * m); - svg_gradient_chain(&local, reference).and_then(|(radial, attrs, stops)| svg_gradient(radial, &attrs, stops, to_unit)) - } else { - svg_defs.get(reference).cloned() - }; - if let Some(g) = resolved { - fills = vec![Paint::gradient(g)]; - } else { + let Some(reference) = style_fill.or(attr_fill).and_then(svg_paint_ref).filter(|_| fills.is_empty()) else { return fills }; + let mut local = BTreeMap::new(); + if let Some(defs) = v.get("svgDefs") { + svg_gradient_nodes(defs, None, 0, &mut local); + } + let resolved = if local.contains_key(reference) { + let to_local = if typ == "path" { abs.inverse() } else { Affine::translate((-x, -y)) }; + let to_unit = svg_space_matrix(v).map(|m| Affine::scale_non_uniform(1.0 / w, 1.0 / h) * to_local * m); + svg_gradient_chain(&local, reference).and_then(|(radial, attrs, stops)| svg_gradient(radial, &attrs, stops, to_unit)) + } else { + self.svg_defs.get(reference).cloned() + }; + match resolved { + Some(g) => vec![Paint::gradient(g)], + None => { warn( - warnings, + self.warnings, format!("Penpot SVG paint-server url(#{reference}) cannot be represented as a native gradient; original SVG attributes retained"), ); + fills } } } - let mut strokes = paints(z, base, v, "strokes", doc, images, image_bytes, inflate_budget, warnings); - // Media metadata and image reads are optional, but exhausting the shared - // decompression budget is never optional: abort rather than continue importing. - inflate_budget.check()?; - if typ == "svg-raw" { - fills.clear(); - strokes.clear(); - } - if !fills.is_empty() - && let Some(path) = vector_source - && let Some(node) = doc.node_mut(created) - { - node.kind = NodeKind::Vector { network: filled_network(&path) }; - } - let svg = v.get("svgAttrs"); - let fill_rule = svg.and_then(|a| a.get("style")).and_then(|s| string(s, "fillRule")).or_else(|| svg.and_then(|a| string(a, "fillRule"))); - if fill_rule == Some("evenodd") - && let Some(NodeKind::Vector { network }) = doc.node_mut(created).map(|n| &mut n.kind) - { - for region in &mut network.regions { - region.winding = figcraft_doc::WindingRule::EvenOdd; + + /// A layer's fills: its own, a stand-alone image's from its metadata, or its SVG attributes'. + fn layer_fills(&mut self, v: &Value, typ: &str, abs: Affine, geometry: (f64, f64, f64, f64)) -> Vec { + let mut fills = self.layer_paints(v, "fills"); + // Some Penpot versions keep a stand-alone image's reference in `metadata`, not `fills`. + if typ == "image" + && let Some(metadata) = v.get("metadata") + { + let mut stretched = metadata.clone(); + if let Some(obj) = stretched.as_object_mut() { + obj.insert("keepAspectRatio".into(), Value::Bool(false)); + } + let mut all = vec![serde_json::json!({"fillImage": stretched, "fillOpacity": 1.0})]; + all.extend(list(v, "fills").cloned()); + let temporary = serde_json::json!({ "fills": all }); + fills = self.layer_paints(&temporary, "fills"); } - } - if let Some(node) = doc.node_mut(created) { - // Unlike Node::new's design-tool defaults, absence of a Penpot fill means no fill. - node.fills = fills; - node.strokes = strokes; - node.visible = v.get("hidden").and_then(Value::as_bool) != Some(true); - node.locked = v.get("blocked").and_then(Value::as_bool) == Some(true); - node.opacity = number(v, "opacity", 1.0).clamp(0.0, 1.0) as f32; - node.blend = string(v, "blendMode").and_then(BlendMode::from_name).unwrap_or(BlendMode::Normal); - node.clip = string(v, "type") == Some("frame") && v.get("showContent").and_then(Value::as_bool) != Some(true); - node.is_mask = v.get("mask").and_then(Value::as_bool) == Some(true); - node.mask_type = match string(v, "maskType") { - Some("vector") => MaskType::Vector, - Some("luminance") => MaskType::Luminance, - _ => MaskType::Alpha, - }; - node.constraints.horizontal = match string(v, "constraintsH") { - Some("right") => HConstraint::Right, - Some("leftright") | Some("left-right") => HConstraint::LeftRight, - Some("center") => HConstraint::Center, - Some("scale") => HConstraint::Scale, - _ => HConstraint::Left, - }; - node.constraints.vertical = match string(v, "constraintsV") { - Some("bottom") => VConstraint::Bottom, - Some("topbottom") | Some("top-bottom") => VConstraint::TopBottom, - Some("center") => VConstraint::Center, - Some("scale") => VConstraint::Scale, - _ => VConstraint::Top, - }; - node.effects = effects(v, warnings); - if node.kind.is_frame_like() { - node.grids = display_grids(v, warnings); - node.guides = penpot_guides(v.get("guides")); + if matches!(typ, "rect" | "circle" | "path" | "bool") { + fills = self.svg_fills(v, typ, abs, geometry, fills); } + fills + } + + /// Penpot layer `id` and its subtree, under `parent` (whose absolute transform is + /// `parent_abs`), in grid `cell` of its parent when it has one. + fn add_layer(&mut self, id: &str, parent: NodeId, parent_abs: Affine, cell: Option<&Value>, depth: usize) -> Result<(), FormatError> { + if depth >= MAX_DEPTH { + warn(self.warnings, "Penpot layer nesting deeper than 128 was omitted".into()); + return Ok(()); + } + if !self.seen.insert(id.to_owned()) { + return Ok(()); + } + let shapes = self.shapes; + let Some(v) = shapes.get(id) else { + warn(self.warnings, format!("Missing layer {id}")); + return Ok(()); + }; + *self.count += 1; + if *self.count > MAX_LAYERS { + return Err(corrupt("too many layers")); + } + let name = string(v, "name").unwrap_or("Layer").chars().take(512).collect::(); + let x = geometry(v, "x", 0.0); + let y = geometry(v, "y", 0.0); + let w = geometry(v, "width", 1.0).clamp(0.01, 1_000_000.0); + let h = geometry(v, "height", 1.0).clamp(0.01, 1_000_000.0); + let typ = string(v, "type").unwrap_or(""); + let (kind, path_box, vector_source) = layer_kind(v, typ, self.masters.contains_key(id), (x, y, w, h), self.warnings); + let abs = if kind.bounds_follow_children() { Affine::translate((x, y)) } else { shape_matrix(v, x, y, w, h) }; + let (abs, w, h) = match path_box { + Some(b) => (abs * Affine::translate(b.origin().to_vec2()), b.width().max(0.01), b.height().max(0.01)), + None => (abs, w, h), + }; + let created = self.doc.create(parent, kind, &name, 0.0, 0.0, w, h).map_err(|e| corrupt(format!("layer '{name}': {e}")))?; + let mut fills = self.layer_fills(v, typ, abs, (x, y, w, h)); + let mut strokes = self.layer_paints(v, "strokes"); + // Media metadata and image reads are optional, but exhausting the shared + // decompression budget is never optional: abort rather than continue importing. + self.inflate_budget.check()?; if typ == "svg-raw" { - // If future SVG support improves, keep the original tree (when small) - // alongside the reconstructed markup for faithful re-import. - preserve_source(node, v, &["content", "svgAttrs", "svg-attrs"]); - if let Some(xml) = raw_svg_xml(v) { - // Avoid unbounded retained markup in huge archives or projects. - if xml.len() <= (8 * 1024 * 1024usize).saturating_sub(*svg_bytes) { - *svg_bytes += xml.len(); - node.plugin_data.insert("penpot.svgRawXml".into(), xml); - node.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into()); + fills.clear(); + strokes.clear(); + } + if !fills.is_empty() + && let Some(path) = vector_source + && let Some(node) = self.doc.node_mut(created) + { + node.kind = NodeKind::Vector { network: filled_network(&path) }; + } + let svg = v.get("svgAttrs"); + let fill_rule = svg.and_then(|a| a.get("style")).and_then(|s| string(s, "fillRule")).or_else(|| svg.and_then(|a| string(a, "fillRule"))); + if fill_rule == Some("evenodd") + && let Some(NodeKind::Vector { network }) = self.doc.node_mut(created).map(|n| &mut n.kind) + { + for region in &mut network.regions { + region.winding = figcraft_doc::WindingRule::EvenOdd; + } + } + if let Some(node) = self.doc.node_mut(created) { + // Unlike Node::new's design-tool defaults, absence of a Penpot fill means no fill. + node.fills = fills; + node.strokes = strokes; + apply_appearance(node, v); + node.effects = effects(v, self.warnings); + if node.kind.is_frame_like() { + node.grids = display_grids(v, self.warnings); + node.guides = penpot_guides(v.get("guides")); + } + if typ == "svg-raw" { + keep_raw_svg(node, v, &name, self.svg_bytes, self.warnings); + } + if typ == "text" { + keep_font_refs(node, v); + } + keep_ids(node, v, id, self.base, self.masters.get(id)); + apply_child_layout(v, cell, node); + // The prototype links are kept until every page is in the document, when the + // Penpot shape ids they name can be resolved to FigCraft node ids. + prototype::keep_interaction_source(node, v, self.warnings); + keep_source(node, v, typ); + node.constrain_proportions = v.get("proportionLock").and_then(Value::as_bool) == Some(true); + settle_text_fills(node, typ); + let radius = number(v, "rx", 0.0).max(0.0); + node.corners.radii = [ + number(v, "r1", radius).max(0.0), + number(v, "r2", radius).max(0.0), + number(v, "r3", radius).max(0.0), + number(v, "r4", radius).max(0.0), + ]; + node.transform = parent_abs.inverse() * abs; + if let Some(stroke) = list(v, "strokes").next() { + apply_stroke(node, stroke); + } + } + self.add_children(v, id, created, abs, depth)?; + finish_layout(self.doc, created, v, self.warnings); + finish_masked_group(self.doc, created, v, self.warnings); + Ok(()) + } + + /// Layer `created`'s auto layout and its children: those `v` lists, in its stacking order, then + /// any whose parent id names it but that the list left out. + fn add_children(&mut self, v: &Value, id: &str, created: NodeId, abs: Affine, depth: usize) -> Result<(), FormatError> { + if v.get("layout").is_some() || v.get("layoutMode").is_some() { + let layout = layout_props(v, self.warnings); + if let Some(node) = self.doc.node_mut(created) { + if !node.kind.is_frame_like() { + warn(self.warnings, "Layout on non-frame-like Penpot layer retained as metadata".into()); } else { - warn(warnings, format!("SVG layer '{name}' exceeds the 8 MiB raw SVG import budget; kept as frame")); + node.layout = layout; } - } else { - warn(warnings, format!("SVG layer '{name}' has no safely readable SVG markup; kept as frame")); } } - if typ == "text" { - let references = referenced_google_fonts(v); - if !references.is_empty() { - node.plugin_data.insert("penpot.googleFontRefs".into(), serde_json::to_string(&references).unwrap_or_default()); - } - // A `custom-*` identifier refers to Penpot's separately managed - // font service. It is not a URL and does not imply that the binary - // is contained in this `.penpot` archive. Preserve the exact id so - // the desktop importer can report or resolve it without guessing. - let custom = referenced_custom_fonts(v); - if !custom.is_empty() { - node.plugin_data.insert("penpot.customFontRefs".into(), serde_json::to_string(&custom).unwrap_or_default()); - } - } - node.plugin_data.insert("penpot.id".into(), id.to_owned()); - if let Some(file_id) = base.strip_prefix("files/") { - node.plugin_data.insert("penpot.fileId".into(), file_id.to_owned()); - } - if let Some(component) = masters.get(id) { - node.plugin_data.insert("penpot.componentMasterId".into(), component.clone()); - } - if v.get("componentId").is_some() && !masters.contains_key(id) { - node.plugin_data.insert("penpot.componentLinkStatus".into(), "not-linked".into()); - } - apply_child_layout(v, cell, node); - // The prototype links are kept until every page is in the document, when the - // Penpot shape ids they name can be resolved to FigCraft node ids. - prototype::keep_interaction_source(node, v, warnings); - // Mark unsupported properties on their exact source nodes. Layout changes may - // otherwise silently discard these Penpot-specific rules on subsequent edits. - if string(v, "layoutItemAlignSelf") - .is_some_and(|a| !matches!(a, "auto" | "start" | "flex-start" | "center" | "end" | "flex-end" | "stretch" | "baseline")) + let mut children: Vec = list(v, "shapes").filter_map(Value::as_str).map(str::to_owned).collect(); + if let Some(layout) = + self.doc.node_mut(created).and_then(|n| n.layout.as_mut()).filter(|l| matches!(l.mode, LayoutMode::Horizontal | LayoutMode::Vertical)) { - node.plugin_data.insert("penpot.unsupported.alignSelf".into(), "true".into()); + layout.reverse_z = true; + children.reverse(); } - preserve_source( - node, - v, - &[ - "layout", - "layoutMode", - "layoutFlexDir", - "layoutWrapType", - "layoutGap", - "layoutPadding", - "layoutJustifyContent", - "layoutAlignItems", - "layoutAlignContent", - "layoutGridColumns", - "layoutGridRows", - "layoutGridCells", - "layoutItemMargin", - "layoutItemAlignSelf", - "layoutItemAbsolute", - "layoutItemHSizing", - "layoutItemVSizing", - "touched", - "appliedTokens", - "interactions", - "variantProperties", - "pluginData", - "grids", - "guides", - "maskType", - "svgAttrs", - "svgViewbox", - "svgDefs", - "layoutGapType", - "layoutPaddingType", - "layoutJustifyItems", - "layoutGridDir", - "componentRoot", - "mainInstance", - "layoutItemZIndex", - "exports", - "hideInViewer", - "strokes", - ], - ); - if typ == "text" { - preserve_source_within(node, v, &["content", "positionData"], 256 * 1024); + let cells = grid_cells(v); + for child in children { + self.add_layer(&child, created, abs, cells.get(child.as_str()).copied(), depth + 1)?; } - for key in ["componentId", "componentFile", "shapeRef", "variantId"] { - if let Some(value) = string(v, key) - && value.len() <= 1024 - { - node.plugin_data.insert(format!("penpot.{key}"), value.to_owned()); - } - } - node.constrain_proportions = v.get("proportionLock").and_then(Value::as_bool) == Some(true); - if let NodeKind::Text(text) = &mut node.kind - && let Some(paints) = uniform_text_fills(text) - { - node.fills = paints; - text.style.fills = None; - for run in &mut text.runs { - run.style.fills = None; - } - } - if typ == "text" && node.fills.is_empty() { - node.fills.push(Paint::solid(Color::BLACK)); - } - let radius = number(v, "rx", 0.0).max(0.0); - node.corners.radii = - [number(v, "r1", radius).max(0.0), number(v, "r2", radius).max(0.0), number(v, "r3", radius).max(0.0), number(v, "r4", radius).max(0.0)]; - node.transform = parent_abs.inverse() * abs; - if let Some(stroke) = list(v, "strokes").next() { - node.stroke.weight = number(stroke, "strokeWidth", 1.0).clamp(0.0, 10000.0); - node.stroke.align = match string(stroke, "strokeAlignment") { - Some("center") => StrokeAlign::Center, - Some("outer") | Some("outside") => StrokeAlign::Outside, - _ => StrokeAlign::Inside, - }; - node.stroke.join = match string(stroke, "strokeJoin") { - Some("round") => StrokeJoin::Round, - Some("bevel") => StrokeJoin::Bevel, - _ => StrokeJoin::Miter, - }; - let cap = match string(stroke, "strokeCapStart") { - Some("round") => StrokeCap::Round, - Some("square") => StrokeCap::Square, - Some("line-arrow") => StrokeCap::LineArrow, - Some("triangle-arrow") => StrokeCap::TriangleArrow, - _ => StrokeCap::None, - }; - node.stroke.cap_start = cap; - node.stroke.cap_end = match string(stroke, "strokeCapEnd") { - Some("round") => StrokeCap::Round, - Some("square") => StrokeCap::Square, - Some("line-arrow") => StrokeCap::LineArrow, - Some("triangle-arrow") => StrokeCap::TriangleArrow, - _ => cap, - }; - let w = node.stroke.weight; - node.stroke.dashes = match string(stroke, "strokeStyle") { - Some("dotted") => vec![0.0, w + 5.0], - Some("dashed") => vec![w + 10.0, w + 10.0], - Some("mixed") => vec![w + 5.0, w + 5.0, w + 1.0, w + 5.0], - _ => Vec::new(), - }; - if string(stroke, "strokeStyle") == Some("dotted") { - let dot = |cap: StrokeCap| if matches!(cap, StrokeCap::LineArrow | StrokeCap::TriangleArrow) { cap } else { StrokeCap::Round }; - (node.stroke.cap_start, node.stroke.cap_end) = (dot(node.stroke.cap_start), dot(node.stroke.cap_end)); + // `shapes` may be incomplete in a damaged or partially exported document. Recover + // genuine descendants through their declared parent without reordering listed layers. + let children_by_parent = self.children_by_parent; + if let Some(unlinked) = children_by_parent.get(id) { + for child in unlinked { + if !self.seen.contains(child) { + warn(self.warnings, "Penpot group omitted a child from its stacking list; recovered it by parentId".into()); + self.add_layer(child, created, abs, cells.get(child.as_str()).copied(), depth + 1)?; + } } } + Ok(()) } - if v.get("layout").is_some() || v.get("layoutMode").is_some() { - let layout = layout_props(v, warnings); - if let Some(node) = doc.node_mut(created) { - if !node.kind.is_frame_like() { - warn(warnings, "Layout on non-frame-like Penpot layer retained as metadata".into()); - } else { - node.layout = layout; - } - } - } - let mut children: Vec = list(v, "shapes").filter_map(Value::as_str).map(str::to_owned).collect(); - if let Some(layout) = - doc.node_mut(created).and_then(|n| n.layout.as_mut()).filter(|l| matches!(l.mode, LayoutMode::Horizontal | LayoutMode::Vertical)) - { - layout.reverse_z = true; - children.reverse(); - } - let cells = grid_cells(v); - for child in children { - add_layer( - z, - base, - shapes, - children_by_parent, - svg_defs, - masters, - &child, - created, - abs, - cells.get(child.as_str()).copied(), - doc, - images, - image_bytes, - inflate_budget, - svg_bytes, - warnings, - seen, - depth + 1, - count, - )?; - } - // `shapes` may be incomplete in a damaged or partially exported document. Recover - // genuine descendants through their declared parent without reordering listed layers. - if let Some(unlinked) = children_by_parent.get(id) { - for child in unlinked { - if !seen.contains(child) { - warn(warnings, "Penpot group omitted a child from its stacking list; recovered it by parentId".into()); - add_layer( - z, - base, - shapes, - children_by_parent, - svg_defs, - masters, - child, - created, - abs, - cells.get(child.as_str()).copied(), - doc, - images, - image_bytes, - inflate_budget, - svg_bytes, - warnings, - seen, - depth + 1, - count, - )?; - } - } - } - finish_layout(doc, created, v, warnings); - finish_masked_group(doc, created, v, warnings); - Ok(()) } /// Exact font-provider references supplied by Penpot's text content tree. diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index c09552e16..1de09e89d 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -746,46 +746,13 @@ impl<'a> Exporter<'a> { } } let parent_node = node.parent.and_then(|p| self.node(p)); - let parent_layout = parent_node.and_then(|p| p.layout.as_ref()); let mut fields = Map::new(); fields.insert("parentId".into(), json!(parent)); fields.insert("frameId".into(), json!(frame)); fields.insert("pageId".into(), json!(page_id)); fields.insert("fills".into(), Value::Array(self.paints(&node.fills, false))); fields.insert("strokes".into(), Value::Array(self.strokes(node, typ))); - if !node.visible { - fields.insert("hidden".into(), json!(true)); - } - if node.locked { - fields.insert("blocked".into(), json!(true)); - } - if (node.opacity - 1.0).abs() > 1e-6 { - fields.insert("opacity".into(), num(f64::from(node.opacity))); - } - if let Some(name) = blend_name(node.blend).filter(|n| *n != "normal") { - fields.insert("blendMode".into(), json!(name)); - } - fields.insert("proportionLock".into(), json!(node.constrain_proportions)); - fields.insert( - "constraintsH".into(), - json!(match node.constraints.horizontal { - HConstraint::Left => "left", - HConstraint::Right => "right", - HConstraint::LeftRight => "leftright", - HConstraint::Center => "center", - HConstraint::Scale => "scale", - }), - ); - fields.insert( - "constraintsV".into(), - json!(match node.constraints.vertical { - VConstraint::Top => "top", - VConstraint::Bottom => "bottom", - VConstraint::TopBottom => "topbottom", - VConstraint::Center => "center", - VConstraint::Scale => "scale", - }), - ); + appearance_fields(node, &mut fields); self.effects(node, &mut fields); if matches!(typ, "rect" | "frame") { for (i, key) in ["r1", "r2", "r3", "r4"].iter().enumerate() { @@ -798,15 +765,7 @@ impl<'a> Exporter<'a> { if typ == "bool" && let NodeKind::BooleanOp { op } = node.kind { - fields.insert( - "boolType".into(), - json!(match op { - BoolOp::Union => "union", - BoolOp::Subtract => "difference", - BoolOp::Intersect => "intersection", - BoolOp::Exclude => "exclude", - }), - ); + fields.insert("boolType".into(), json!(bool_type(op))); } if typ == "frame" { fields.insert("showContent".into(), json!(!node.clip)); @@ -831,53 +790,11 @@ impl<'a> Exporter<'a> { node.name )); } - if parent_layout.is_some() || node.child_layout != figcraft_doc::ChildLayout::default() { - self.child_layout(node, &mut fields); - } - if let Some(parent) = parent_node - && self.unchanged_layouts.contains(&parent.id) - { - for key in [ - "layoutItemHSizing", - "layoutItemVSizing", - "layoutItemAbsolute", - "layoutItemMargin", - "layoutItemMarginType", - "layoutItemAlignSelf", - "layoutItemMinW", - "layoutItemMaxW", - "layoutItemMinH", - "layoutItemMaxH", - ] { - if let Some(value) = source_value(node, key) { - fields.insert(key.into(), value); - } - } - } + self.item_fields(node, parent_node, &mut fields); if typ == "text" && let NodeKind::Text(text) = &node.kind { - fields.insert("fills".into(), json!([])); - let unchanged = matches_fingerprint(node, "text", text_fingerprint(node)); - match (unchanged, source_value(node, "content")) { - (true, Some(content)) => { - fields.insert("content".into(), content); - if let Some(position) = source_value(node, "positionData") { - fields.insert("positionData".into(), position); - } - } - _ => { - fields.insert("content".into(), self.text_content(node, text)); - } - } - fields.insert( - "growType".into(), - json!(match text.auto_resize { - TextAutoResize::WidthAndHeight => "auto-width", - TextAutoResize::Height => "auto-height", - TextAutoResize::None | TextAutoResize::Truncate => "fixed", - }), - ); + self.text_fields(node, text, &mut fields); } if typ == "svg-raw" && let Some(content) = svg_raw_source(node) @@ -906,6 +823,60 @@ impl<'a> Exporter<'a> { Some(pid) } + /// A layer's fields as an auto layout child: its own, and the source's while its parent's layout + /// is unchanged. + fn item_fields(&mut self, node: &Node, parent_node: Option<&Node>, fields: &mut Map) { + if parent_node.and_then(|p| p.layout.as_ref()).is_some() || node.child_layout != figcraft_doc::ChildLayout::default() { + self.child_layout(node, fields); + } + if let Some(parent) = parent_node + && self.unchanged_layouts.contains(&parent.id) + { + for key in [ + "layoutItemHSizing", + "layoutItemVSizing", + "layoutItemAbsolute", + "layoutItemMargin", + "layoutItemMarginType", + "layoutItemAlignSelf", + "layoutItemMinW", + "layoutItemMaxW", + "layoutItemMinH", + "layoutItemMaxH", + ] { + if let Some(value) = source_value(node, key) { + fields.insert(key.into(), value); + } + } + } + } + + /// A text layer's fields: its content (the source's while its text is unchanged) and how it grows; + /// its fills are in the content. + fn text_fields(&mut self, node: &Node, text: &figcraft_doc::TextData, fields: &mut Map) { + fields.insert("fills".into(), json!([])); + let unchanged = matches_fingerprint(node, "text", text_fingerprint(node)); + match (unchanged, source_value(node, "content")) { + (true, Some(content)) => { + fields.insert("content".into(), content); + if let Some(position) = source_value(node, "positionData") { + fields.insert("positionData".into(), position); + } + } + _ => { + fields.insert("content".into(), self.text_content(node, text)); + } + } + fields.insert( + "growType".into(), + json!(match text.auto_resize { + TextAutoResize::WidthAndHeight => "auto-width", + TextAutoResize::Height => "auto-height", + TextAutoResize::None | TextAutoResize::Truncate => "fixed", + }), + ); + } + fn svg_attrs(&self, node: &Node, typ: &str) -> Option { let mut attrs = source_value(node, "svgAttrs").and_then(|v| v.as_object().cloned()); if !matches!(typ, "rect" | "circle" | "path" | "bool") { @@ -1806,3 +1777,50 @@ impl<'a> Exporter<'a> { serde_json::to_string_pretty(&Value::Object(root)).ok() } } + +/// A layer's visibility, lock, opacity, blend, proportion lock and constraints as Penpot fields. +fn appearance_fields(node: &Node, fields: &mut Map) { + if !node.visible { + fields.insert("hidden".into(), json!(true)); + } + if node.locked { + fields.insert("blocked".into(), json!(true)); + } + if (node.opacity - 1.0).abs() > 1e-6 { + fields.insert("opacity".into(), num(f64::from(node.opacity))); + } + if let Some(name) = blend_name(node.blend).filter(|n| *n != "normal") { + fields.insert("blendMode".into(), json!(name)); + } + fields.insert("proportionLock".into(), json!(node.constrain_proportions)); + fields.insert( + "constraintsH".into(), + json!(match node.constraints.horizontal { + HConstraint::Left => "left", + HConstraint::Right => "right", + HConstraint::LeftRight => "leftright", + HConstraint::Center => "center", + HConstraint::Scale => "scale", + }), + ); + fields.insert( + "constraintsV".into(), + json!(match node.constraints.vertical { + VConstraint::Top => "top", + VConstraint::Bottom => "bottom", + VConstraint::TopBottom => "topbottom", + VConstraint::Center => "center", + VConstraint::Scale => "scale", + }), + ); +} + +/// A boolean operation's Penpot `boolType`. +fn bool_type(op: BoolOp) -> &'static str { + match op { + BoolOp::Union => "union", + BoolOp::Subtract => "difference", + BoolOp::Intersect => "intersection", + BoolOp::Exclude => "exclude", + } +} From 7c9184b023fb114517107148ee8f95c5fb206581 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 14:48:37 +0700 Subject: [PATCH 10/30] docs: rows from the round-4 community PRs (#858, #1409, #1397, #1389, #1143, #1427, #1421) (#1504) --- ROADMAP.md | 8 +++++++- docs/file-format-parity.md | 15 +++++++++++---- docs/gaps.md | 21 +++++++++++++++++---- docs/roadmap.md | 5 ++++- docs/ui-parity.md | 4 +++- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 75c807ffe..b650f08e7 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-29 · **Change:** minor (images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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-29 · **Change:** minor (offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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 @@ -136,7 +136,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **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-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged. - **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. +- **2026-10-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. +- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. 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** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity 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. - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). @@ -352,14 +355,17 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged | | 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) | | 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. | | 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-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. | | 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. | +| 2026-10-27 | minor | M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. | | 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 | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity 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 | | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b0a6b48e9..8e861c7eb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,9 +17,10 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | -| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | +| `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | +| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | +| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | direct `.jam` opening is not supported (#1342); its `canvas.fig` can be read best effort after renaming a copy to `.fig`, with generated sticky/shape backgrounds and text kept (#1341), as ordinary design layers rather than native board objects (see [boards-parity](boards-parity.md)); writing `.jam` is not supported; `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -183,7 +184,13 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| | 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged | +| 2026-10-27 | minor | M6, #1397: damaged generated FigJam shape geometry falls back to usable outer fill or stroke outlines before a rectangle; a surviving sticky author no longer suppresses the outer body-text fallback. Synthetic regressions cover both outlines, missing/invalid/blank bodies, author retention and layer budgets. Numbers unchanged. | +| 2026-10-27 | patch | M6, #1143: merge main and isolate offline Penpot support; preserve conversion warnings, retain nested token-set bindings, bound missing-flow warnings and keep export regressions registered. Numbers unchanged. | | 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged | +| 2026-10-26 | minor | M6, #1397: generated FigJam backgrounds read saved vector networks before rectangle fallback; missing or invalid generated text uses the outer text without discarding successful children or exceeding the layer budget. Regression tests cover both paths. Numbers unchanged. | +| 2026-10-26 | minor | FigJam generated sticky notes and shapes with text (#1341): backgrounds, text with its typography and paints, saved bounds and sticky author lines import as editable frame children; synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. | +| 2026-10-26 | minor | `.sketch` import (#1389) checked rule by rule against Sketch's own renders of files built for each case and against real files (Sketch 50, 63 and 2026): instances show a master's background only when it is included in instances and exports, symbols clip only when set to, group fills are tints only from Sketch 64, an outline mask's opacity fades what it clips, a bitmap's image lies under its fills, image overrides reach shapes' image fills, stretching stacks fill items across, blur radii and colour adjustments follow Sketch's maths, OKLab and OKLCH gradients become sRGB stops, auto-width texts grow from their aligned edge, shape group operands lose their own borders. Known limitations listed in the row. Numbers unchanged | +| 2026-10-25 | minor | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. | | 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged | | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged | | 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before | diff --git a/docs/gaps.md b/docs/gaps.md index d3d6b1b4c..7180a50b9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -16,7 +16,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe |---|---|---|---|---| | 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, then (2026-10-17) more fidelity on synthetic files, budgets for large files, an import report and paste from another design app; an experimental export, also as a copy from Save as (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) | | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | -| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | +| 3 | No representative corpus checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | | 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and panel mirroring pending | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) | @@ -79,6 +79,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe `.figcraft` path. Layers copied from another design app paste (`edit.pasteDesignLayers`, and Cmd/Ctrl+V when the clipboard's HTML holds a `.fig` canvas): read like a `.fig` file, with its warnings, size-checked (256 MB) before decoding. +- **Fixed (2026-10-26, #1341):** generated FigJam sticky notes and shapes with text keep their + background, text, typography, text paint and saved child bounds, including sticky author lines, + as editable frame children. Synthetic fixtures cover generated records, missing bounds, + unsupported versions, saved vector networks without geometry paths, outer fill/stroke outlines + when generated geometry is damaged, outer body-text fallback beside a surviving author and + layer budgets; the issue sample was checked locally only. Native + board metadata and direct `.jam` opening (#1342) remain separate work. - **Landed (2026-10-26):** `.fig` import reads vector-region fill stacks; vector editing and path operations retain them with their source paint coordinates. `.fig` export still warns and drops region fills. Evidence: `crates/format/src/fig/tail_tests.rs`, `crates/engine/src/shapebuilder_region_paints_tests.rs`. @@ -86,7 +93,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe connectors without a saved snapshot, table cells beyond their own layers, live region style/variable links, variables bound to fields the editor can't bind and video fills aren't read; text lays out again with - local fonts. Nothing has been checked against real-world files yet (gap 3): the 2026-10-17 + local fonts. Beyond the local FigJam regression check for #1341, there is no real-world corpus (gap 3): the 2026-10-17 changes were tested on files built in code, and paste from another design app on clipboard HTML made from our own export, never on a real clipboard. The export was checked in other software on our example documents and small test files only. @@ -130,7 +137,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 42–84 h: accounts, teams and per-person permissions, shared comments and versions (numbers from before the server landed; re-measured next). -### 3. Nothing checked against real Figma files +### 3. No representative corpus checked against real Figma files - **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and compared with Figma's own PNG/SVG/PDF exports. - **Evidence:** every corpus in the repo is synthetic (`crates/svg/tests/corpus`, the synthetic @@ -139,6 +146,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Impact:** unknown fidelity in auto layout sizing, text metrics, effects and component resolution on real files; the numbers in this repo can't be trusted beyond "estimated" until it exists. +- **Local regression check (2026-10-26):** the public FigJam issue sample in #1341 was read + locally to check its generated backgrounds, text and author lines. It is not committed and + does not establish representative design-file fidelity. - **Measured so far:** text box sizes and auto layout results of our own example documents, against the same documents re-saved by other software (which lays text out again). Three text rules explained every difference and are applied (whole-pixel line heights, advances on a @@ -535,6 +545,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-28 | minor | Gap 7: the browser refines a zoom's preview only once the zoom has paused for 120 ms, so a wheel zoom no longer runs a blocking full render between every two steps. Numbers unchanged | | 2026-10-27 | minor | M12: the MCP agent feedback workflow landed (comments as tasks, `comment.prompt`, Copy for AI, `fix_comments` / `watch_comments`, `figcraft://comments`, a bounded event log, `events.poll` and `wait_for_changes`). Figma AI features and local models are still not started, so gap 19's missing list and estimate stand | +| 2026-10-27 | minor | M6, #1397: damaged generated FigJam shape geometry falls back to usable outer fill or stroke outlines before a rectangle; a surviving sticky author no longer suppresses the outer body-text fallback. Synthetic regressions cover both outlines, missing/invalid/blank bodies, author retention and layer budgets. Numbers unchanged. | +| 2026-10-26 | minor | M6, #1397: generated FigJam backgrounds read saved vector networks before rectangle fallback; missing or invalid generated text uses the outer text without discarding successful children or exceeding the layer budget. Regression tests cover both paths. Numbers unchanged. | +| 2026-10-26 | minor | FigJam generated sticky notes and shapes with text (#1341): backgrounds, text with its typography and paints, saved bounds and sticky author lines import as editable frame children; synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. | | 2026-10-25 | minor | egui/eframe 0.37 verified for gap 18: the remaining limitations are unchanged. egui-wgpu 0.37 still picks an 8-bit sRGB surface with no colour-space option, so P3 colours outside sRGB still clip on screen; eframe 0.37 still gives only the main window an AccessKit adapter, so other windows have no screen-reader tree. Numbers unchanged | | 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged | | 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 090c4a4a3..180de4ab2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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 (M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -78,6 +78,9 @@ blockers. | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-27 | minor | Bounded attached MCP replies (M8, #1113): `figcraft-cli mcp --attach` read each reply from the app's control port with no size limit, so a process listening on that port could make it buffer until memory ran out. Replies are now read up to 512 MiB (an app window capture is at most 256 MiB as base64); reading stops at the first byte past that, the connection is closed and the call is reported like any other lost reply, and the next call reconnects. Regression tests in the MCP backend tests. Numbers unchanged | | 2026-10-27 | minor | Undo kept after a failed batch (M8, #1410): a batch that undid an earlier edit and then failed restored the document but lost that edit's undo step; a failed group now restores the undo entries as well as the redo entries, so the earlier edit can still be undone and redone. Regression tests in the batch rollback suite. Numbers unchanged | +| 2026-10-27 | minor | Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return per-step outcomes; references to failed steps fail with both step numbers, successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged | +| 2026-10-27 | minor | Alt during a drag (M1, #1385): Alt (Option) turns a move into a copy whenever it goes down, before the drag or during it, and letting go of it during the drag moves the original again; the undo step is named for what the drag did, and only a copy snaps to the layers it came from. Regression test in the duplicate spacing tests. Numbers unchanged | +| 2026-10-27 | minor | Variables in props (M3, #1000): `node.create` and `node.set` take `{$var: variable id or name}` for `fill` and `stroke`, including unambiguous `Collection/name` lookups; bindings apply after literal values and style links and clear the paint's style link, with the same type checks, alpha and modes as `variable.bind`, in one undo step. Regression tests cover lookups, ordering, batch references, rollback and undo/redo. Numbers unchanged | | 2026-10-27 | minor | Finding layers without selecting them (M0.9, part of #996): `node.find` returns ids and a total with AND filters for name, canonical type, text and main component name, under a root or on the current page; ids are limited, the count is not. The selection, page and undo history stay unchanged. Available through `run_command` and batch step references. Regression tests in `crates/engine/src/node_find_tests.rs` and `crates/mcp/tests/it/agent_tasks.rs`. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 02bf1859b..599ff48b7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -19,6 +19,7 @@ interaction feel not yet compared. | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | +| Design panel's sections on one grid | done | every section lays its rows out on the same grid: the panel is 240 pt, its content 16 pt in from the left and 8 pt from the right, two 88 pt columns at that width with 8 pt gutters and a 24 pt column for the buttons at a row's end (the columns are flex, so they share the panel when it is dragged wider); rows 24 pt in a 32 pt track, 10 pt captions over them; section titles 11 pt semibold and a row's values 11 pt, their buttons 24 pt and 4 pt apart. The panel's edge is drawn inside it: a frame stroke counts as margin and took 1 pt off each side, putting every right margin 1–2 pt out (checked in the app on Windows over the control channel) | | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | | Context menus | partial | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key reaches egui as `Key::Menu` (egui-winit 0.37 maps the native key), but FigCraft's keymap has no binding for `Menu`, so it does nothing yet | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | @@ -174,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | +| 2026-10-27 | minor | The Design panel's sections sit on one grid (#858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows, 11 pt semibold section titles over 10 pt captions, 4 pt between the two buttons at a row's end; the panel's frame no longer carries a stroke (it counts as margin), so an icon's box ends 8 pt from the window edge. Checked in the app on Windows over the control channel | | 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged | | 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | | 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | From 7ba22fb2b6dcc4401e7749a3bbb4cb1cd2a0e2a4 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 15:07:21 +0700 Subject: [PATCH 11/30] M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464) (#1501) * M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464) * M1: crop mode shows the image dimmed outside the layer and heavier crop marks (#1464) * M1: the handle overlay draws gradients and crops in their own helpers (#1464) --- ROADMAP.md | 3 +- crates/color/src/lib.rs | 16 +- crates/engine/src/canvas/tools/drag.rs | 9 +- crates/engine/src/catalog.rs | 4 +- crates/engine/src/cmd/image.rs | 50 ++++- crates/engine/src/image_crop_tests.rs | 110 ++++++++++ crates/engine/src/lib.rs | 2 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/app/commands.rs | 10 + crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/canvas/handles.rs | 245 +++++++++++++++++----- crates/ui-egui/src/canvas/overlays.rs | 28 ++- crates/ui-egui/src/crop_image_tests.rs | 130 ++++++++++++ crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/icons/draw.rs | 5 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/menus.rs | 7 + crates/ui-egui/src/panels/right/paints.rs | 9 + crates/ui-egui/src/shortcuts.rs | 4 + docs/mcp.md | 3 + docs/roadmap.md | 3 +- docs/ui-parity.md | 5 +- 22 files changed, 573 insertions(+), 81 deletions(-) create mode 100644 crates/engine/src/image_crop_tests.rs create mode 100644 crates/ui-egui/src/crop_image_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index b650f08e7..8da4e1bea 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-29 · **Change:** minor (offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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-29 · **Change:** minor (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | | 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged | | 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) | diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 6e61084d6..42fb6a69c 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -486,9 +486,23 @@ impl ImagePaint { { return c; } + self.fitted_crop(iw, ih, w, h, false) + } + + /// The crop transform that keeps the picture where its scale mode draws it: the crop in effect + /// in Crop mode, the Fit placement in Fit mode, else (Fill, Tile) the Fill placement. + pub fn placement_crop(&self, iw: f64, ih: f64, w: f64, h: f64) -> [f64; 6] { + match self.scale_mode { + ImageScaleMode::Crop => self.effective_crop(iw, ih, w, h), + mode => self.fitted_crop(iw, ih, w, h, mode == ImageScaleMode::Fit), + } + } + + /// The Fill placement (`fit`: the Fit placement) as a crop transform. + fn fitted_crop(&self, iw: f64, ih: f64, w: f64, h: f64, fit: bool) -> [f64; 6] { let (rw, rh) = self.rotated_size(iw.max(1.0), ih.max(1.0)); let (w, h) = (w.max(1e-6), h.max(1e-6)); - let s = (w / rw).max(h / rh); + let s = if fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; let (x0, y0) = ((w - rw * s) / 2.0, (h - rh * s) / 2.0); [w / (rw * s), 0.0, 0.0, h / (rh * s), -x0 / (rw * s), -y0 / (rh * s)] } diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index a192e3383..e0f56a10a 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -546,15 +546,8 @@ impl Session { } let Some(data) = doc.images.get(&img.image) else { continue }; let (iw, ih) = (f64::from(data.width), f64::from(data.height)); - let crop = if img.scale_mode == ImageScaleMode::Crop { - img.effective_crop(iw, ih, w, h) - } else { - let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); - let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; - [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] - }; // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. - let crop = (Affine::new(crop) * to_old).as_coeffs(); + let crop = (Affine::new(img.placement_crop(iw, ih, w, h)) * to_old).as_coeffs(); if !crop.iter().all(|c| c.is_finite()) { continue; } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 5180b3400..62236b8e7 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -151,6 +151,8 @@ pub static MENU: &[MenuItem] = &[ m!("Object", "Use as mask", "object.mask"), m!("Object", "Release mask", "object.releaseMask"), m!("Object", "Set as thumbnail", "object.setThumbnail"), + // FigCraft's own (in `OWN`): the way into crop mode that the Design panel also offers. + m!("Object", "Crop image", "image.crop"), m!("Object", SEP, ""), m!("Object", "Bring to front", "arrange.front"), m!("Object", "Bring forward", "arrange.forward"), @@ -290,7 +292,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"]; +const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs index c82e30d1d..cefd723b6 100644 --- a/crates/engine/src/cmd/image.rs +++ b/crates/engine/src/cmd/image.rs @@ -1,12 +1,12 @@ -//! Image paints: adjustment presets, built in and saved in the file. +//! Image paints: adjustment presets, built in and saved in the file, and Crop image. -use figcraft_color::{ImageFilters, PaintKind}; -use figcraft_doc::{Document, ImagePreset}; +use figcraft_color::{ImageFilters, ImageScaleMode, PaintKind}; +use figcraft_doc::{Document, ImagePreset, Node}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets}; -use crate::{Result, Session, bad, spec}; +use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ @@ -34,6 +34,13 @@ pub fn specs() -> Vec { } ok() }), + spec!( + "image.crop", + "Crop image", + "{ids? (one layer), index? (an image fill; default its top visible one)} → {id, index}: the image fill switches to Crop, keeping the picture where it was, and the canvas enters crop mode on it", + true, + crop + ), spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| { let built_in: Vec = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect(); Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets})) @@ -135,6 +142,41 @@ fn adjust(s: &mut Session, p: &Value) -> Result { Ok(json!({"changed": changed})) } +/// The index of a layer's top visible image fill (fills paint bottom to top). +pub fn top_image_fill(n: &Node) -> Option { + n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_))) +} + +fn crop(s: &mut Session, p: &Value) -> Result { + let cmd = "image.crop"; + let ids = req_targets(cmd, s, p)?; + // A selection with nothing to crop is refused as unavailable, not as bad input. + let refuse = |why: &str| EngineError::Disabled(cmd.into(), why.into()); + let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) }; + let doc = s.doc()?; + let n = doc.get(id)?; + let index = match get(p, "index") { + None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?, + Some(_) => req_index(cmd, p, "index")?, + }; + let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?; + let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) }; + // An image already in Crop mode only enters crop mode: no edit, no undo step. + if img.scale_mode != ImageScaleMode::Crop { + let data = doc.images.get(&img.image).ok_or_else(|| bad(cmd, "the image's data is missing"))?; + img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height)); + img.scale_mode = ImageScaleMode::Crop; + let n = s.doc_mut()?.get_mut(id)?; + if let Some(f) = n.fills.get_mut(index) { + *f = paint; + } + // Like any paint edit, the layer leaves its paint style. + n.styles.fill = None; + } + s.request_ui(cmd, json!({"id": id, "index": index})); + Ok(json!({"id": id, "index": index})) +} + fn save_preset(s: &mut Session, p: &Value) -> Result { let cmd = "image.savePreset"; let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); diff --git a/crates/engine/src/image_crop_tests.rs b/crates/engine/src/image_crop_tests.rs new file mode 100644 index 000000000..c7403dfba --- /dev/null +++ b/crates/engine/src/image_crop_tests.rs @@ -0,0 +1,110 @@ +//! `image.crop` (Object ▸ Crop image, the right-click menu and the image fill row): the layer's +//! top visible image fill (or the one `index` names) switches to Crop with the picture left where +//! its old mode drew it, as one undo step, and the canvas is asked to enter crop mode. A layer +//! without an image fill, or several layers, are refused with a reason and nothing changes. + +use base64::Engine as _; +use figcraft_color::{ImageScaleMode, Paint, PaintKind}; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// A 200×100 synthetic image layer (every pixel differs), resized to 200×150 so Fill and Fit +/// place the picture differently. +fn image_layer(s: &mut Session) -> NodeId { + let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner()); + let id = id_of(&ex(s, "file.import", json!({"data": data, "name": "crop.png", "x": 0, "y": 0}))["id"]).unwrap(); + ex(s, "node.set", json!({"ids": [id], "props": {"height": 150}})); + id +} + +fn fills(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().node(id).unwrap().fills.clone() +} + +fn set_mode(s: &mut Session, id: NodeId, mode: ImageScaleMode) { + let mut f = fills(s, id); + let PaintKind::Image(img) = &mut f[0].kind else { panic!("image fill") }; + img.scale_mode = mode; + ex(s, "node.set", json!({"ids": [id], "props": {"fills": f}})); +} + +fn pixels(s: &Session, id: NodeId) -> Vec { + figcraft_render::render_node(s.doc().unwrap(), id, 1.0).unwrap().data().to_vec() +} + +fn undo_steps(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +#[test] +fn crop_image_switches_to_crop_keeping_the_picture_as_one_undo_step() { + for mode in [ImageScaleMode::Fill, ImageScaleMode::Fit, ImageScaleMode::Crop] { + let mut s = Session::new(); + let layer = image_layer(&mut s); + set_mode(&mut s, layer, mode); + let (before, doc_before, steps) = (pixels(&s, layer), s.doc().unwrap().clone(), undo_steps(&s)); + s.ui_requests.clear(); + let r = ex(&mut s, "image.crop", json!({})); + assert_eq!(r, json!({"id": layer, "index": 0}), "{mode:?}"); + let PaintKind::Image(img) = &fills(&s, layer)[0].kind else { panic!("image fill") }; + assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode:?}"); + assert_eq!(pixels(&s, layer), before, "{mode:?}: the picture stays where it was"); + let asked: Vec<_> = s.ui_requests.iter().filter(|u| u.kind == "image.crop").map(|u| u.data.clone()).collect(); + assert_eq!(asked, vec![json!({"id": layer, "index": 0})], "{mode:?}: the canvas enters crop mode"); + if mode == ImageScaleMode::Crop { + assert_eq!(undo_steps(&s), steps, "already cropped: nothing to undo"); + continue; + } + assert_eq!(undo_steps(&s), steps + 1, "{mode:?}: one undo step"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap(), &doc_before, "{mode:?}: undo restores the fill"); + } +} + +#[test] +fn crop_image_takes_the_top_visible_image_fill_or_the_one_named() { + let mut s = Session::new(); + let layer = image_layer(&mut s); + let mut f = fills(&s, layer); + let image = f[0].clone(); + let mut hidden = image.clone(); + hidden.visible = false; + // Bottom to top: image, image, solid, hidden image. + f = vec![image.clone(), image, Paint::solid(figcraft_color::Color::BLACK), hidden]; + ex(&mut s, "node.set", json!({"ids": [layer], "props": {"fills": f}})); + assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer]}))["index"], 1, "the top visible image fill"); + let modes = + |s: &Session| fills(s, layer).iter().map(|p| if let PaintKind::Image(i) = &p.kind { Some(i.scale_mode) } else { None }).collect::>(); + assert_eq!(modes(&s), [Some(ImageScaleMode::Fill), Some(ImageScaleMode::Crop), None, Some(ImageScaleMode::Fill)]); + assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer], "index": 0}))["index"], 0, "an image fill by index"); + assert_eq!(modes(&s)[0], Some(ImageScaleMode::Crop)); + let err = s.execute("image.crop", &json!({"ids": [layer], "index": 2})).unwrap_err().to_string(); + assert!(err.contains("not an image"), "{err}"); + let err = s.execute("image.crop", &json!({"ids": [layer], "index": 9})).unwrap_err().to_string(); + assert!(err.contains("no fill at that index"), "{err}"); +} + +#[test] +fn crop_image_is_refused_without_one_image_layer() { + let mut s = Session::new(); + let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 300, "y": 0, "width": 50, "height": 50}))["id"]).unwrap(); + let layer = image_layer(&mut s); + let doc = s.doc().unwrap().clone(); + s.ui_requests.clear(); + for (ids, why) in [(json!([rect]), "no image fill"), (json!([rect, layer]), "select one layer")] { + let err = s.execute("image.crop", &json!({"ids": ids})).unwrap_err(); + assert!(matches!(&err, crate::EngineError::Disabled(..)) && err.to_string().contains(why), "{err}"); + } + assert_eq!(s.doc().unwrap(), &doc, "nothing changes"); + assert!(s.ui_requests.is_empty(), "no crop mode"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 998bae240..d36f1905c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -307,6 +307,8 @@ mod huge_stroke_tests; #[cfg(test)] mod id_params_behaviour_tests; #[cfg(test)] +mod image_crop_tests; +#[cfg(test)] mod images_left_in_file_tests; #[cfg(test)] mod import_export_tests; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 8e5561510..8707e3e9f 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -368,6 +368,7 @@ name = "English" "Create style from the selected text" = "Create style from the selected text" "Created" = "Created" "Crop" = "Crop" +"Crop image" = "Crop image" "Crop marks" = "Crop marks" "Ctrl+arrows" = "Ctrl+arrows" "Ctrl+click" = "Ctrl+click" diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 4ccb89391..dc34885a9 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -226,6 +226,16 @@ impl FigApp { "layers.collapseAll" => layers::collapse_all(self), "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), + "image.crop" => { + // Crop mode takes the canvas: an open colour picker would edit the same paint. + self.ui.picker = None; + self.ui.crop = r + .data + .get("id") + .and_then(figcraft_engine::params::id_of) + .zip(r.data.get("index").and_then(Value::as_u64)) + .map(|(id, i)| (id, i as usize)); + } "canvas.renameLabel" => { self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true }); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index faf105286..a7e41c34f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -173,7 +173,8 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers sy: f64::from(pos.y), shift: m.shift, alt: m.alt, - cmd: m.command, + // Crop mode: resizing the layer crops it, as Cmd/Ctrl-resizing does. + cmd: m.command || app.ui.crop.is_some(), ctrl: m.ctrl, zoom, pressure: app.canvas.pressure, diff --git a/crates/ui-egui/src/canvas/handles.rs b/crates/ui-egui/src/canvas/handles.rs index d29d59ee0..137e2c92b 100644 --- a/crates/ui-egui/src/canvas/handles.rs +++ b/crates/ui-egui/src/canvas/handles.rs @@ -40,22 +40,27 @@ enum Drag { /// The Crop-mode image being edited: (node, fills, index, paint, image rect in the unit box). type UnitRect = (f64, f64, f64, f64); +/// The paint open in the colour picker, else the image fill in crop mode (while its layer is the +/// selection). fn crop_target(app: &FigApp) -> Option<(NodeId, bool, usize, ImagePaint, UnitRect)> { - let Some(PickerTarget::Paint { fills, index, ids }) = &app.ui.picker else { return None }; - if ids.len() != 1 { - return None; - } - let id = *ids.first()?; + let (id, fills, index) = match (&app.ui.picker, app.ui.crop) { + (Some(PickerTarget::Paint { fills, index, ids }), _) => match ids.as_slice() { + [id] => (*id, *fills, *index), + _ => return None, + }, + (None, Some((id, index))) if app.session.selection() == [id] => (id, true, index), + _ => return None, + }; let doc = app.session.doc().ok()?; let n = doc.node(id)?; - let list = if *fills { &n.fills } else { &n.strokes }; - let PaintKind::Image(img) = &list.get(*index)?.kind else { return None }; + let list = if fills { &n.fills } else { &n.strokes }; + let PaintKind::Image(img) = &list.get(index)?.kind else { return None }; if img.scale_mode != ImageScaleMode::Crop { return None; } let data = doc.images.get(&img.image)?; let crop = img.effective_crop(f64::from(data.width), f64::from(data.height), n.width, n.height); - Some((id, *fills, *index, img.clone(), ImagePaint::crop_rect(crop))) + Some((id, fills, index, img.clone(), ImagePaint::crop_rect(crop))) } fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Pos2; 4]> { @@ -67,6 +72,114 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po ]) } +/// How opaque the image shows outside the layer in crop mode (premultiplied white tint). +const GHOST_ALPHA: u8 = 115; +/// The longest side of the crop overlay's texture: the dimmed part needs no more. +const GHOST_MAX_SIDE: usize = 2048; + +/// The image paint's texture for the crop overlay and the image's own size: uploaded once (from +/// the renderer's decoded copy, at most [`GHOST_MAX_SIDE`] a side) and kept while the same image, +/// adjustments and colour profile are cropped. +fn ghost_texture(ctx: &egui::Context, doc: &figcraft_doc::Document, img: &ImagePaint) -> Option<(egui::TextureId, f64, f64)> { + type Entry = std::sync::Arc<(String, figcraft_color::ImageFilters, figcraft_color::ColorProfile, egui::TextureHandle, f64, f64)>; + let id = Id::unique("figcraft_crop_ghost"); + if let Some(e) = ctx.data(|d| d.get_temp::(id)) + && e.0 == img.image + && e.1 == img.filters + && e.2 == doc.color_profile + { + return Some((e.3.id(), e.4, e.5)); + } + let pm = figcraft_render::images::get(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile)?; + let (w, h) = (pm.width() as usize, pm.height() as usize); + let step = w.max(h).div_ceil(GHOST_MAX_SIDE).max(1); + let (tw, th) = (w.div_ceil(step), h.div_ceil(step)); + let data = pm.data(); + let mut px = Vec::with_capacity(tw * th * 4); + for y in (0..h).step_by(step) { + for x in (0..w).step_by(step) { + let i = (y * w + x) * 4; + px.extend_from_slice(data.get(i..i + 4)?); + } + } + let tex = ctx.load_texture("crop-ghost", egui::ColorImage::from_rgba_premultiplied([tw, th], &px), egui::TextureOptions::LINEAR); + let tid = tex.id(); + let e: Entry = std::sync::Arc::new((img.image.clone(), img.filters, doc.color_profile, tex, w as f64, h as f64)); + ctx.data_mut(|d| d.insert_temp(id, e)); + Some((tid, w as f64, h as f64)) +} + +/// The image outside the layer's box, dimmed, so cropping shows what it cuts away. `r` is the +/// image's rectangle in the layer's unit box: the bands of it outside 0..1 are drawn. +fn crop_ghost(app: &FigApp, painter: &egui::Painter, id: NodeId, img: &ImagePaint, r: UnitRect) { + // Only an axis-aligned crop has a rectangle in the unit box (as the crop handles assume). + if img.crop.is_some_and(|m| m[1] != 0.0 || m[2] != 0.0) { + return; + } + let Ok(doc) = app.session.doc() else { return }; + let Some(n) = doc.node(id) else { return }; + let Some((tex, iw, ih)) = ghost_texture(painter.ctx(), doc, img) else { return }; + let (w, h) = (n.width.max(1e-9), n.height.max(1e-9)); + // Unit box → image pixels → texture coordinates. + let t = figcraft_render::paint::image_transform(img, iw, ih, w, h); + let to_image = figcraft_geom::Affine::new([t.sx, t.ky, t.kx, t.sy, t.tx, t.ty].map(f64::from)).inverse(); + if !to_image.as_coeffs().iter().all(|c| c.is_finite()) { + return; + } + let uv = |u: f64, v: f64| { + let p = to_image * Point::new(u * w, v * h); + pos2((p.x / iw) as f32, (p.y / ih) as f32) + }; + let (x0, y0, x1, y1) = (r.0.min(r.2), r.1.min(r.3), r.0.max(r.2), r.1.max(r.3)); + let (cy0, cy1) = (y0.max(0.0), y1.min(1.0)); + let bands = [(x0, y0, x1, y1.min(0.0)), (x0, y0.max(1.0), x1, y1), (x0, cy0, x1.min(0.0), cy1), (x0.max(1.0), cy0, x1, cy1)]; + let tint = Color32::from_white_alpha(GHOST_ALPHA); + let mut mesh = egui::Mesh::with_texture(tex); + for (bx0, by0, bx1, by1) in bands.into_iter().filter(|b| b.2 - b.0 > 1e-9 && b.3 - b.1 > 1e-9) { + let base = mesh.vertices.len() as u32; + for (u, v) in [(bx0, by0), (bx1, by0), (bx1, by1), (bx0, by1)] { + let Some(pos) = unit_to_screen(app, id, (u, v)) else { return }; + mesh.vertices.push(egui::epaint::Vertex { pos, uv: uv(u, v), color: tint }); + } + mesh.indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } + if !mesh.indices.is_empty() { + painter.add(Shape::mesh(mesh)); + } +} + +/// Crop mode's marks on the layer's box, heavier than the selection's square handles (hidden in +/// crop mode): L-shaped brackets at the corners and short bars across the middle of each edge, +/// in the selection colour. +fn crop_marks(app: &FigApp, painter: &egui::Painter, id: NodeId) { + let Some(b) = crop_corners(app, id, (0.0, 0.0, 1.0, 1.0)) else { return }; + const WIDTH: f32 = 5.0; + let (halo, mark) = (Stroke::new(WIDTH + 2.0, Color32::from_black_alpha(50)), Stroke::new(WIDTH, crate::theme::canvas_accent(painter.ctx()))); + let draw = |pts: Vec| { + painter.add(Shape::line(pts.clone(), halo)); + painter.add(Shape::line(pts, mark)); + }; + for i in 0..4 { + let (Some(p), Some(prev), Some(next)) = (b.get(i), b.get((i + 3) % 4), b.get((i + 1) % 4)) else { continue }; + let (to_prev, to_next) = ((*prev - *p).normalized(), (*next - *p).normalized()); + // Inside the box, flush with its edges: along one edge is inward from the other. + let at = *p + (to_prev + to_next) * (WIDTH / 2.0); + let leg = |dir: egui::Vec2, len: f32| at + dir * 22.0_f32.min(len / 3.0); + draw(vec![leg(to_prev, (*prev - *p).length()), at, leg(to_next, (*next - *p).length())]); + let len = (*next - *p).length(); + let mid = *p + (*next - *p) * 0.5 + to_prev * (WIDTH / 2.0); + let half = to_next * 12.0_f32.min(len / 6.0); + draw(vec![mid - half, mid + half]); + } +} + +/// Whether screen point `pos` is on a resize handle (a corner or an edge) of the selection's box. +fn on_resize_handle(app: &FigApp, pos: Pos2) -> bool { + let Ok(ds) = app.session.ds() else { return false }; + let handle = figcraft_engine::tools::handle_at(&ds.doc, &ds.selection, app.to_page(pos), ds.view.zoom); + matches!(handle, Some(figcraft_engine::tools::Handle::Corner(_) | figcraft_engine::tools::Handle::Edge(_))) +} + /// Stable egui id for handle dragging. fn drag_id() -> Id { Id::unique("figcraft_handle_drag") @@ -275,40 +388,67 @@ fn al_zones(app: &FigApp) -> Option { Some(Zones { id, bands, gaps, tracks }) } +/// The gradient's line, stops and end handles on the canvas. +fn draw_gradient(app: &FigApp, painter: &egui::Painter, id: NodeId, g: &figcraft_color::Gradient) { + let (Some(a), Some(b), Some(c)) = (unit_to_screen(app, id, g.from), unit_to_screen(app, id, g.to), unit_to_screen(app, id, g.width)) else { + return; + }; + painter.line_segment([a, b], Stroke::new(2.0, Color32::WHITE)); + painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120))); + if g.kind != figcraft_color::GradientKind::Linear { + painter.add(Shape::dashed_line(&[a, c], Stroke::new(1.0, Color32::WHITE), 4.0, 3.0)); + } + let selected: usize = painter.ctx().data(|d| d.get_temp(crate::colorpicker::stop_id())).unwrap_or(0); + for (i, s) in g.stops.iter().enumerate() { + let p = a + (b - a) * s.position; + let r = Rect::from_center_size(p, vec2(12.0, 12.0)); + painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0))); + painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + if i == selected { + painter.rect_stroke( + r.expand(2.0), + CornerRadius::same(3), + Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())), + StrokeKind::Outside, + ); + } + } + for (i, p) in [a, b, c].iter().enumerate() { + if i == 2 && g.kind == figcraft_color::GradientKind::Linear { + continue; + } + painter.circle_filled(*p, 5.0, Color32::WHITE); + painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); + } +} + +/// The image's crop outline and corner handles, its dimmed outside and, in crop mode, the marks. +fn draw_crop(app: &FigApp, painter: &egui::Painter, id: NodeId, img: &ImagePaint, r: UnitRect) { + let Some(c) = crop_corners(app, id, r) else { return }; + crop_ghost(app, painter, id, img, r); + let outline = vec![c[0], c[1], c[2], c[3], c[0]]; + painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE))); + painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())))); + for p in c { + let hr = Rect::from_center_size(p, vec2(8.0, 8.0)); + painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE); + painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside); + } + if app.ui.crop.is_some() { + crop_marks(app, painter, id); + } +} + /// Draw the handles. -pub fn draw(app: &FigApp, painter: &egui::Painter) { - let hover = painter.ctx().input(|i| i.pointer.hover_pos()); +pub fn draw(app: &mut FigApp, painter: &egui::Painter) { + // Crop mode ends when a colour picker opens, or when its layer leaves the selection or its fill + // is no longer a Crop image (an undo). + if app.ui.crop.is_some() && (app.ui.picker.is_some() || crop_target(app).is_none()) { + app.ui.crop = None; + } + let app = &*app; if let Some((id, _, _, g)) = gradient_target(app) { - let (Some(a), Some(b), Some(c)) = (unit_to_screen(app, id, g.from), unit_to_screen(app, id, g.to), unit_to_screen(app, id, g.width)) else { - return; - }; - painter.line_segment([a, b], Stroke::new(2.0, Color32::WHITE)); - painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120))); - if g.kind != figcraft_color::GradientKind::Linear { - painter.add(Shape::dashed_line(&[a, c], Stroke::new(1.0, Color32::WHITE), 4.0, 3.0)); - } - let selected: usize = painter.ctx().data(|d| d.get_temp(crate::colorpicker::stop_id())).unwrap_or(0); - for (i, s) in g.stops.iter().enumerate() { - let p = a + (b - a) * s.position; - let r = Rect::from_center_size(p, vec2(12.0, 12.0)); - painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0))); - painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); - if i == selected { - painter.rect_stroke( - r.expand(2.0), - CornerRadius::same(3), - Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())), - StrokeKind::Outside, - ); - } - } - for (i, p) in [a, b, c].iter().enumerate() { - if i == 2 && g.kind == figcraft_color::GradientKind::Linear { - continue; - } - painter.circle_filled(*p, 5.0, Color32::WHITE); - painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); - } + draw_gradient(app, painter, id, &g); return; } if let Some((id, _, e)) = blur_target(app, painter.ctx()) { @@ -322,18 +462,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE)); return; } - if let Some((id, _, _, _, r)) = crop_target(app) { - let Some(c) = crop_corners(app, id, r) else { return }; - let outline = vec![c[0], c[1], c[2], c[3], c[0]]; - painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE))); - painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())))); - for p in c { - let hr = Rect::from_center_size(p, vec2(8.0, 8.0)); - painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE); - painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside); - } + if let Some((id, _, _, img, r)) = crop_target(app) { + draw_crop(app, painter, id, &img, r); return; } + let hover = painter.ctx().input(|i| i.pointer.hover_pos()); let tool_move = app.session.tool.tool == figcraft_engine::ToolId::Move && !app.session.tool.busy(); let dragging: Option = painter.ctx().data(|d| d.get_temp(drag_id())); if !tool_move && dragging.is_none() { @@ -452,6 +585,10 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { } } if let Some((id, fills, index, _, rect)) = crop_target(app) { + // Crop mode: the layer's own resize handles crop it (the pointer events carry Cmd/Ctrl). + if app.ui.crop.is_some() && on_resize_handle(app, pos) { + return false; + } let Some(c) = crop_corners(app, id, rect) else { return false }; let start = screen_to_unit(app, id, pos).unwrap_or((0.0, 0.0)); let corner = c.iter().position(|p| (*p - pos).length() <= 8.0); @@ -459,7 +596,13 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { let which = match corner { Some(i) => i, None if inside => 4, - None => return false, + None => { + // A click outside the image ends crop mode and goes on to the tools. + if pressed { + app.ui.crop = None; + } + return false; + } }; ctx.set_cursor_icon(if which == 4 { egui::CursorIcon::Move diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 15af030e0..2d4d82d5f 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -272,16 +272,22 @@ fn selection_overlay( let measuring = app.session.tool.overlay.measure_box.is_some(); // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev // Mode: no handles either. - let handles = - if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) { - 0 - } else if is_line { - 2 - } else if tiny { - 0 - } else { - 4 - }; + // Crop mode draws its own marks on the box instead (`handles::crop_marks`). + let cropping = app.ui.crop.is_some(); + let handles = if cropping + || measuring + || app.session.tool.dev_mode + || connector.is_some() + || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) + { + 0 + } else if is_line { + 2 + } else if tiny { + 0 + } else { + 4 + }; size_label = transform_box( app, painter, @@ -296,7 +302,7 @@ fn selection_overlay( painter.circle_filled(c, 5.0, Color32::WHITE); painter.circle_stroke(c, 5.0, stroke(col, 1.5)); } - if !measuring { + if !measuring && !cropping { arc_overlay(app, painter, col); shape_overlay(app, painter, col); } diff --git a/crates/ui-egui/src/crop_image_tests.rs b/crates/ui-egui/src/crop_image_tests.rs new file mode 100644 index 000000000..7883e9fe8 --- /dev/null +++ b/crates/ui-egui/src/crop_image_tests.rs @@ -0,0 +1,130 @@ +//! Crop image on the canvas: the right-click menu offers it for one image layer (and not for a +//! layer without an image fill), it enters crop mode with the image's handles, dragging the image +//! moves its crop as one undo step, and Escape, Enter or a click outside the image finishes it +//! without deselecting. + +use base64::Engine as _; +use egui::{Event, Key, Modifiers, Pos2, vec2}; +use figcraft_color::{ImageScaleMode, PaintKind}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; +use crate::present_tests::id; + +/// A 200×100 image layer at (0, 0) and a plain rectangle beside it; the image is selected. +fn setup() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let image = image::RgbaImage::from_fn(40, 20, |x, y| image::Rgba([(x * 6) as u8, (y * 12) as u8, 120, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(png.into_inner()); + let rect = id(&s.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 80, "height": 80})).unwrap()); + let layer = id(&s.execute("image.place", &json!({"data": data, "x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + s.execute("select.set", &json!({"ids": [layer]})).unwrap(); + (Ui::new(s), layer, rect) +} + +fn key(u: &mut Ui, key: Key) { + let modifiers = Modifiers::NONE; + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn mode(u: &Ui, layer: NodeId) -> (ImageScaleMode, Option<[f64; 6]>) { + match &u.app.session.doc().unwrap().node(layer).unwrap().fills[0].kind { + PaintKind::Image(img) => (img.scale_mode, img.crop), + _ => panic!("image fill"), + } +} + +fn menu_has_crop(u: &mut Ui, ids: Vec) -> bool { + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), ids)); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + u.app.ui.context_menu = None; + has_text(&shapes, "Crop image") +} + +#[test] +fn the_context_menu_enters_crop_mode_and_escape_or_enter_finishes_it() { + let (mut u, layer, rect) = setup(); + u.app.run("select.set", json!({"ids": [rect]})); + assert!(!menu_has_crop(&mut u, vec![rect]), "a layer without an image fill has nothing to crop"); + u.app.run("select.set", json!({"ids": [layer]})); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![layer])); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + let at = text_center(&shapes, "Crop image"); + u.button(at, true); + u.button(at, false); + assert_eq!(u.app.ui.crop, Some((layer, 0)), "crop mode on the image fill"); + assert_eq!(mode(&u, layer).0, ImageScaleMode::Crop); + for k in [Key::Escape, Key::Enter] { + u.frame(vec![]); + key(&mut u, k); + assert_eq!(u.app.ui.crop, None, "{k:?} finishes crop mode"); + assert_eq!(u.app.session.selection(), vec![layer], "{k:?} keeps the selection"); + crate::menus::invoke(&mut u.app, "image.crop"); + assert_eq!(u.app.ui.crop, Some((layer, 0)), "Object ▸ Crop image enters it again"); + } +} + +#[test] +fn dragging_the_image_in_crop_mode_moves_its_crop_and_a_click_outside_finishes() { + let (mut u, layer, _) = setup(); + u.app.run("image.crop", json!({})); + u.frame(vec![]); + let steps = u.app.session.ds().unwrap().history.undo.len(); + let (_, before) = mode(&u, layer); + let from = u.app.to_screen(100.0, 50.0); + u.drag_to(from, from + vec2(30.0, 0.0)); + u.button(from + vec2(30.0, 0.0), false); + let (m, after) = mode(&u, layer); + assert_eq!(m, ImageScaleMode::Crop); + assert_ne!(after, before, "the drag moved the image inside the layer"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(u.app.ui.crop, Some((layer, 0)), "still cropping"); + // A click away from the image ends crop mode. + let away = u.app.to_screen(250.0, 300.0); + u.button(away, true); + u.button(away, false); + assert_eq!(u.app.ui.crop, None); +} + +#[test] +fn in_crop_mode_the_layers_edges_crop_without_moving_the_picture() { + let (mut u, layer, _) = setup(); + u.app.run("image.crop", json!({})); + u.frame(vec![]); + let (_, Some(before)) = mode(&u, layer) else { panic!("a crop") }; + let (from, to) = (u.app.to_screen(200.0, 50.0), u.app.to_screen(150.0, 50.0)); + u.drag_to(from, to); + u.button(to, false); + let n = u.app.session.doc().unwrap().node(layer).unwrap().clone(); + assert!((n.width - 150.0).abs() < 1e-6 && n.transform.as_coeffs()[4].abs() < 1e-6, "the right edge moved in: {} wide", n.width); + let (m, Some(after)) = mode(&u, layer) else { panic!("a crop") }; + assert_eq!(m, ImageScaleMode::Crop); + // The unit box shrank to 3/4 of its width over the same pixels: x scale 3/4, same offset. + assert!((after[0] - before[0] * 0.75).abs() < 1e-9 && (after[4] - before[4]).abs() < 1e-9, "{before:?} -> {after:?}"); + assert_eq!(u.app.ui.crop, Some((layer, 0)), "still cropping"); + // The cut-away part shows dimmed: one band right of the layer, textured from the image. + let ghost = |u: &mut Ui| { + flat(&u.frame_output(vec![]).shapes).into_iter().find_map(|s| match s { + egui::Shape::Mesh(m) if m.vertices.iter().all(|v| v.color == egui::Color32::from_white_alpha(115)) => Some(m), + _ => None, + }) + }; + let m = ghost(&mut u).expect("the dimmed image outside the layer"); + assert_ne!(m.texture_id, egui::TextureId::default()); + let (edge, end) = (u.app.to_screen(150.0, 0.0).x, u.app.to_screen(200.0, 0.0).x); + assert_eq!(m.vertices.len(), 4, "one band"); + assert!(m.vertices.iter().all(|v| (v.pos.x - edge).abs() < 0.5 || (v.pos.x - end).abs() < 0.5), "from the layer's edge to the image's"); + assert!(m.vertices.iter().all(|v| v.uv.x >= 0.74 && v.uv.x <= 1.0 + 1e-4), "the image's right quarter"); + key(&mut u, Key::Escape); + assert!(ghost(&mut u).is_none(), "gone with crop mode"); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 219fe85a4..eeacb2756 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -66,6 +66,7 @@ icons! { PaintBucket, Lasso, Cut, + Crop, Boolean, Page, Check, diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index a96314cf5..7eedfdd14 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -326,6 +326,11 @@ fn draw(p: &P, icon: Icon) { p.line(6.0, 10.0, 11.5, 2.5); p.line(10.0, 10.0, 4.5, 2.5); } + Crop => { + // Two right angles overlapping into a frame, each running past the other's corner. + p.poly(&[(4.5, 1.5), (4.5, 11.5), (14.5, 11.5)], false); + p.poly(&[(1.5, 4.5), (11.5, 4.5), (11.5, 14.5)], false); + } Rectangle => p.rect(2.5, 2.5, 13.5, 13.5, 1.0), Line => p.line(2.5, 13.5, 13.5, 2.5), Arrow => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e77ec34df..b72455ec9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -67,6 +67,8 @@ mod context_menu_tests; #[cfg(test)] mod corner_individual_tests; #[cfg(test)] +mod crop_image_tests; +#[cfg(test)] mod cursors_tests; #[cfg(test)] mod design_paste_tests; @@ -410,6 +412,9 @@ pub struct UiState { /// The prototype preview pane floating over the canvas, when open. pub preview: Option, pub picker: Option, + /// Crop mode (`image.crop`): the layer and the index of the image fill whose crop handles + /// show on the canvas until Enter, Escape or a click outside the image. + pub crop: Option<(NodeId, usize)>, /// The colour picker's popover while it is open (its place survives a drag). pub picker_popover: Option, /// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls): @@ -593,6 +598,7 @@ impl Default for UiState { present_view: present::PresentView::default(), preview: None, picker: None, + crop: None, picker_popover: None, picker_button: None, context_menu: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 34e6e69ca..76f220f4f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -476,6 +476,10 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .ok() .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection())) .map(|_| i18n::command("component.combineVariants", "Combine as variants")); + // One layer with an image fill can be cropped (judged on the selection, as above). + let crop = app.session.doc().ok().is_some_and(|doc| { + matches!(app.session.selection().as_slice(), [id] if doc.node(*id).and_then(figcraft_engine::cmd::image::top_image_fill).is_some()) + }); let copy_link = i18n::command("edit.copyLink", "Copy link to selection"); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ @@ -536,6 +540,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "component.combineVariants")); } + if crop && let Some(at) = entries.iter().position(|e| e.1 == "object.mask") { + entries.insert(at + 1, (tr("Crop image"), "image.crop")); + } // An instance's actions reach its main component: replace "Create component" with // "Go to main component" (as in Figma's context menu). if app diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 4d7c5a31a..a24fca55b 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -509,6 +509,15 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, } _ => { child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text); + // One layer's image fill: crop it on the canvas. + if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) { + let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); + let cresp = ui.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image")); + icons::paint(ui.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary }); + if cresp.clicked() { + app.run("image.crop", json!({"ids": ids, "index": i})); + } + } } } let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5a0933e48..c34c587d3 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -462,6 +462,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if key_box(app, key, m) { return true; } + // Enter or Escape finishes crop mode (the crop is already applied). + if matches!(key, Key::Enter | Key::Escape) && m.is_none() && app.ui.crop.take().is_some() { + return true; + } // A gradient open in the colour picker: Delete removes its selected stop. if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) { return true; diff --git a/docs/mcp.md b/docs/mcp.md index be9c392bd..5ddc9964a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -244,6 +244,9 @@ Or in a project's `.mcp.json`: `fade`, `reset`) or one saved in the file, with any `filters` values on top. `image.savePreset {name, filters?}` saves the current (or given) adjustments in the file, `image.deletePreset {name}` removes one and `image.presets` lists both kinds. + `image.crop {ids?, index?}` (one layer) switches its top visible image fill (or fill `index`) to + Crop, keeping the picture where it was, and returns `{id, index}`; the app also enters crop mode + on the canvas. The crop itself is the paint's `crop` transform (`fill.set`). - Text props (`fontFamily`, `fontStyle`, `fontSize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `textFills`, `fontVariations`…) apply to the whole layer, or to chars `start`..`end` when given (`text.style {id, start, end, …}` does the same). Styled ranges come back as diff --git a/docs/roadmap.md b/docs/roadmap.md index 180de4ab2..cc24f8597 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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 (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-27 | minor | Bounded attached MCP replies (M8, #1113): `figcraft-cli mcp --attach` read each reply from the app's control port with no size limit, so a process listening on that port could make it buffer until memory ran out. Replies are now read up to 512 MiB (an app window capture is at most 256 MiB as base64); reading stops at the first byte past that, the connection is closed and the call is reported like any other lost reply, and the next call reconnects. Regression tests in the MCP backend tests. Numbers unchanged | | 2026-10-27 | minor | Undo kept after a failed batch (M8, #1410): a batch that undid an earlier edit and then failed restored the document but lost that edit's undo step; a failed group now restores the undo entries as well as the redo entries, so the earlier edit can still be undone and redone. Regression tests in the batch rollback suite. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 599ff48b7..1503b8e56 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -92,7 +92,7 @@ interaction feel not yet compared. | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | -| Image crop handles | done | | +| Image crop handles; Crop image | done | Object ▸ Crop image, the right-click menu and the crop button on an image fill row (`image.crop`) switch the image to Crop where it already shows and enter crop mode: the whole image shows, dimmed outside the layer; the layer's box carries L-shaped corner brackets and edge bars in the selection colour (in place of the square handles), and its edges and corners crop it with the picture held still (as Cmd/Ctrl-resizing does anywhere), the image drags to move and its corners scale it (Shift: freely); Enter, Escape or a click outside finishes (`image_crop_tests`, `crop_image_tests`). Not compared side by side | ## Canvas @@ -174,6 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Crop image (#1464): Object ▸ Crop image, the right-click menu and a crop button on the image fill row enter crop mode on the canvas (`image.crop`; the image dimmed outside the layer, corner brackets and edge bars on its box; Enter, Escape or a click outside finishes). The row stays done | | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | | 2026-10-27 | minor | The Design panel's sections sit on one grid (#858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows, 11 pt semibold section titles over 10 pt captions, 4 pt between the two buttons at a row's end; the panel's frame no longer carries a stroke (it counts as margin), so an icon's box ends 8 pt from the window edge. Checked in the app on Windows over the control channel | | 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged | From 1df9190d0c244a108da5412b93ae279e82024d62 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 15:43:08 +0700 Subject: [PATCH 12/30] M3: shadows of images with transparent pixels follow their alpha (#1289) (#1509) * M3: shadows of images with transparent pixels follow their alpha (#1289) A drop shadow on an image fill was hidden behind the layer's whole box, so a transparent PNG's shadow only showed outside its bounding box. Fills that paint their own alpha (patterns; images with transparent pixels or that leave part of the box empty) now cast and hide drop shadows, and shade inner shadows, by the layer's pixels, like groups. Opaque pictures keep their outline. Each image is scanned for transparency once. Canvas, PNG, SVG and PDF. * M3: the image shadow tests import their names explicitly (#1289) --- ROADMAP.md | 3 +- crates/pdf/src/raster.rs | 13 +-- crates/render/src/draw.rs | 20 +++-- crates/render/src/image_shadow_tests.rs | 108 ++++++++++++++++++++++++ crates/render/src/images.rs | 17 ++++ crates/render/src/lib.rs | 2 + crates/render/src/paint.rs | 8 ++ crates/render/src/shadow.rs | 26 +++++- crates/svg/src/image_shadow_tests.rs | 59 +++++++++++++ crates/svg/src/lib.rs | 7 +- docs/roadmap.md | 3 +- docs/target-app-parity.md | 6 +- 12 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 crates/render/src/image_shadow_tests.rs create mode 100644 crates/svg/src/image_shadow_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8da4e1bea..484b6dd30 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-29 · **Change:** minor (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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-29 · **Change:** minor (shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | | 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged | | 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged | diff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs index 76ec584bb..341902712 100644 --- a/crates/pdf/src/raster.rs +++ b/crates/pdf/src/raster.rs @@ -11,7 +11,7 @@ //! node on the page, and placed under its content. use figcraft_color::BlendMode; -use figcraft_doc::{EffectKind, NodeId, NodeKind}; +use figcraft_doc::{EffectKind, NodeId}; use figcraft_geom::{Affine, Rect}; use figcraft_render::{Alone, Pixmap, View, backdrop, shadow}; use krilla::geom::Size; @@ -164,11 +164,12 @@ impl Exporter<'_> { } } EffectKind::InnerShadow(sh) => { - // A group is no outline: its layers' pixels, together, are what is shaded. - let inner = match &shape { - Some(o) => shadow::inner_shadow(o, w, h, xf, view.scale, sh), - None if matches!(n.kind, NodeKind::Group) => shadow::inner_shadow_of(&layer, view.scale, sh), - None => None, + // A group is no outline, and image fills paint their own alpha: the layer's + // pixels are what is shaded. + let inner = if shadow::inner_from_pixels(doc, n) { + shadow::inner_shadow_of(&layer, view.scale, sh) + } else { + shape.as_ref().and_then(|o| shadow::inner_shadow(o, w, h, xf, view.scale, sh)) }; if let Some(p) = inner { out.inner.push(Raster::new(&p, rect, self.ink())); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 3ff68026b..679f62872 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -480,9 +480,13 @@ pub fn effect_margin(n: &Node) -> f64 { EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_) => e.backdrop_radius() * 2.0, EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + s.offset.0.abs() + s.offset.1.abs(), EffectKind::Texture(t) => t.radius.abs(), - // A group's inner shadows are cast from around its pixels: a layer cut off at the - // view would cast one from the cut. - EffectKind::InnerShadow(s) if matches!(n.kind, NodeKind::Group) => s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs(), + // Inner shadows cast from around a layer's pixels (groups, image fills with alpha; any + // image counts here): a layer cut off at the view would cast one from the cut. + EffectKind::InnerShadow(s) + if matches!(n.kind, NodeKind::Group) || n.fills.iter().any(|f| matches!(f.kind, PaintKind::Image(_) | PaintKind::Pattern(_))) => + { + s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs() + } _ => 0.0, }) .filter(|m| m.is_finite()) @@ -607,12 +611,16 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u } let op = n.opacity.clamp(0.0, 1.0); { - // Inner shadows paint over the fills, under the children and strokes. + // Inner shadows paint over the fills, under the children and strokes: cast from around the + // outline, or around the fills' pixels where images paint their own alpha. let inner = |l: &mut Pixmap| { + let fills = shadow::inner_from_pixels(ctx.doc, n).then(|| l.clone()); for e in n.effects.iter().filter(|e| e.visible) { if let EffectKind::InnerShadow(s) = &e.kind - && let Some(sh) = &shape - && let Some(inner) = shadow::inner_shadow(sh, lw, lh, lxf, ctx.scale, s) + && let Some(inner) = match &fills { + Some(f) => shadow::inner_shadow_of(f, ctx.scale, s), + None => shape.as_ref().and_then(|sh| shadow::inner_shadow(sh, lw, lh, lxf, ctx.scale, s)), + } { l.draw_pixmap(0, 0, inner.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } diff --git a/crates/render/src/image_shadow_tests.rs b/crates/render/src/image_shadow_tests.rs new file mode 100644 index 000000000..0ef693603 --- /dev/null +++ b/crates/render/src/image_shadow_tests.rs @@ -0,0 +1,108 @@ +//! Shadows of image fills follow what the image paints (#1289): a picture with transparent pixels +//! casts its drop shadow from its alpha and hides it only behind its pixels, and its inner shadow +//! shades the inside of its alpha; an opaque picture keeps its outline. + +use std::sync::Arc; + +use figcraft_color::{Color, ImageScaleMode, Paint}; +use figcraft_doc::{Document, Effect, EffectKind, ImageData, NodeId, NodeKind, Shadow}; + +use super::{Pixmap, View, render_nodes_into, shadow}; + +/// A 200 × 200 PNG, red: a circle of radius 50 in its middle on transparent pixels (`circle`), or +/// all over. +fn png(circle: bool) -> Vec { + let img = image::RgbaImage::from_fn(200, 200, |x, y| { + let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 100.0); + image::Rgba(if !circle || r <= 50.0 { [255, 0, 0, 255] } else { [0; 4] }) + }); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A 200 × 200 rectangle at (100, 100) filled with the image, with `effect`, at `opacity`. +fn scene(circle: bool, effect: EffectKind, opacity: f32) -> (Document, NodeId) { + let mut d = Document::new("image shadow"); + let p = d.first_page().unwrap(); + // Image ids key the shared decode cache: fixtures running in parallel need ids of their own. + let id = if circle { "image-shadow-tests-circle" } else { "image-shadow-tests-opaque" }; + d.images.insert(id.into(), ImageData { bytes: Arc::new(png(circle)), width: 200, height: 200 }); + let n = d.create(p, NodeKind::Rectangle, "R", 100.0, 100.0, 200.0, 200.0).unwrap(); + let node = d.get_mut(n).unwrap(); + node.fills = vec![Paint::image(id, ImageScaleMode::Fill)]; + node.effects = vec![Effect { kind: effect, visible: true }]; + node.opacity = opacity; + (d, n) +} + +fn shadow(offset: (f64, f64), show_behind: bool) -> Shadow { + Shadow { color: Color::BLACK, offset, radius: 0.0, show_behind, ..Default::default() } +} + +/// Node `id` drawn on a transparent 400 × 400 view of the page. +fn render(d: &Document, id: NodeId) -> Pixmap { + let mut pix = Pixmap::new(400, 400).unwrap(); + render_nodes_into(d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }, &mut pix); + pix +} + +/// The premultiplied RGBA at the page point `(x, y)`. +fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] { + let i = ((y * p.width() + x) * 4) as usize; + p.data()[i..i + 4].try_into().unwrap() +} + +fn near(v: u8, want: u8) -> bool { + v.abs_diff(want) <= 3 +} + +#[test] +fn a_transparent_images_drop_shadow_follows_its_alpha() { + for show_behind in [false, true] { + // At half opacity, a shadow under the circle shows through it. + let (d, n) = scene(true, EffectKind::DropShadow(shadow((20.0, 20.0), show_behind)), 0.5); + assert!(shadow::cover(&d, d.node(n).unwrap()).is_none(), "the image's pixels hide its shadow, not its box"); + let p = render(&d, n); + // Inside the box, outside the circle, inside the circle's shadow: the shadow. + let s = px(&p, 265, 220); + assert!(s[..3] == [0, 0, 0] && near(s[3], 128), "show behind {show_behind}: {s:?}"); + // Inside the box, away from the circle and its shadow: nothing. + assert_eq!(px(&p, 150, 150), [0; 4]); + // Where the circle covers the shadow, the option decides. + let c = px(&p, 225, 225); + assert!(near(c[3], if show_behind { 191 } else { 128 }), "show behind {show_behind}: {c:?}"); + } +} + +#[test] +fn an_opaque_images_drop_shadow_still_hides_behind_its_box() { + let (d, n) = scene(false, EffectKind::DropShadow(shadow((20.0, 20.0), false)), 0.5); + let node = d.node(n).unwrap(); + assert!(shadow::cover(&d, node).is_some() && shadow::silhouette(&d, node).is_some(), "an opaque fill keeps its outline"); + let p = render(&d, n); + assert!(near(px(&p, 250, 250)[3], 128), "behind the picture: hidden"); + let s = px(&p, 310, 250); + assert!(s[..3] == [0, 0, 0] && near(s[3], 128), "past it: the shadow {s:?}"); + // Fit leaves part of a wider box empty: the pixels decide there too. + let (mut d, n) = scene(false, EffectKind::DropShadow(shadow((20.0, 20.0), false)), 0.5); + d.get_mut(n).unwrap().width = 300.0; + d.get_mut(n).unwrap().fills = vec![Paint::image("image-shadow-tests-opaque", ImageScaleMode::Fit)]; + assert!(shadow::cover(&d, d.node(n).unwrap()).is_none()); +} + +#[test] +fn a_transparent_images_inner_shadow_shades_its_alpha() { + let (d, n) = scene(true, EffectKind::InnerShadow(shadow((0.0, 10.0), false)), 1.0); + let p = render(&d, n); + // The top 10 of the circle (it starts at y 150) is in shadow; its middle stays red. + assert_eq!(px(&p, 200, 155), [0, 0, 0, 255]); + assert_eq!(px(&p, 200, 200), [255, 0, 0, 255]); + // The box's top edge is transparent: no shadow there. + assert_eq!(px(&p, 200, 105), [0; 4]); + // An opaque picture is shaded from its box's edge, as before. + let (d, n) = scene(false, EffectKind::InnerShadow(shadow((0.0, 10.0), false)), 1.0); + let p = render(&d, n); + assert_eq!(px(&p, 200, 105), [0, 0, 0, 255]); + assert_eq!(px(&p, 200, 200), [255, 0, 0, 255]); +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 380b67126..d41e533d3 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -123,6 +123,23 @@ pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, m } } +/// Whether image `hash`, decoded as `pm`, has no transparent pixels (filters and colour profiles +/// keep alpha). Scanned once per image. +pub(crate) fn opaque(hash: &str, pm: &Pixmap) -> bool { + static C: OnceLock>> = OnceLock::new(); + let c = C.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(o) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(hash) { + return *o; + } + let o = pm.pixels().iter().all(|p| p.alpha() == u8::MAX); + let mut c = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.len() > 4096 { + c.clear(); + } + c.insert(hash.to_string(), o); + o +} + fn profile_cache() -> &'static Mutex>> { static C: OnceLock>>> = OnceLock::new(); C.get_or_init(|| Mutex::new(HashMap::new())) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 858dc27ae..321c467e2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -492,6 +492,8 @@ mod image_budget_tests; #[cfg(test)] mod image_profile_tests; #[cfg(test)] +mod image_shadow_tests; +#[cfg(test)] mod jpeg_sampling_tests; #[cfg(test)] mod lod_tests; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 86e7d3965..b83aa0cbb 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -164,6 +164,14 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim } } +/// Whether image paint `img` paints its whole `w` × `h` box opaque: an image without transparent +/// pixels that covers the box (or tiles it). +pub(crate) fn fills_box_opaque(doc: &Document, img: &ImagePaint, w: f64, h: f64) -> bool { + let Some(pm) = crate::images::get(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile) else { return false }; + (img.scale_mode == ImageScaleMode::Tile || covers_box(img, f64::from(pm.width()), f64::from(pm.height()), w, h)) + && crate::images::opaque(&img.image, &pm) +} + /// Whether an image paint covers its whole `w` × `h` box: Fill always, Crop when its frame does. fn covers_box(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> bool { match img.scale_mode { diff --git a/crates/render/src/shadow.rs b/crates/render/src/shadow.rs index 4966e8e4d..7ed7c4d65 100644 --- a/crates/render/src/shadow.rs +++ b/crates/render/src/shadow.rs @@ -12,6 +12,7 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex, OnceLock}; +use figcraft_color::{Paint, PaintKind}; use figcraft_doc::{Document, EffectKind, Node, NodeKind, SPREAD_MITER_LIMIT, Shadow}; use figcraft_geom::{Affine, BezPath}; use tiny_skia::{FillRule, Pixmap, PixmapPaint, Transform}; @@ -27,7 +28,8 @@ pub type Outline = (BezPath, FillRule); /// The outlines a node's shadows are cast by when they spread, in local space: its fill outline /// (when a fill shows) and its stroke area. `None` when the layer's pixels aren't those outlines: -/// groups, frames that don't clip their children or show no fill, text with a stroke. +/// groups, frames that don't clip their children or show no fill, text with a stroke, layers +/// filled only with images or patterns (whose transparent pixels cast no shadow). pub fn silhouette(doc: &Document, n: &Node) -> Option> { outlines(doc, n, |f| f.visible && f.opacity > 0.0) } @@ -39,7 +41,26 @@ pub fn cover(doc: &Document, n: &Node) -> Option> { outlines(doc, n, |f| f.visible) } -fn outlines(doc: &Document, n: &Node, fill_counts: fn(&figcraft_color::Paint) -> bool) -> Option> { +/// Whether the fills `counts` picks (one at least) all paint their own alpha within `n`'s outline, +/// not the whole outline: patterns, and images with transparent pixels or that leave part of the +/// box empty (Fit, a smaller crop). The layer's pixels then cast and hide its shadows. +fn alpha_fills(doc: &Document, n: &Node, counts: impl Fn(&Paint) -> bool) -> bool { + let mut fills = n.fills.iter().filter(|f| counts(f)).peekable(); + fills.peek().is_some() + && fills.all(|f| match &f.kind { + PaintKind::Pattern(_) => true, + PaintKind::Image(img) => !crate::paint::fills_box_opaque(doc, img, n.width, n.height), + PaintKind::Solid { .. } | PaintKind::Gradient(_) => false, + }) +} + +/// Whether `n`'s inner shadows are cast from around its pixels, not its outline: groups (no +/// outline of their own) and layers whose fills paint their own alpha (see [`alpha_fills`]). +pub fn inner_from_pixels(doc: &Document, n: &Node) -> bool { + matches!(n.kind, NodeKind::Group) || alpha_fills(doc, n, |f| f.visible) +} + +fn outlines(doc: &Document, n: &Node, fill_counts: fn(&Paint) -> bool) -> Option> { let has_fill = n.fills.iter().any(fill_counts); let is_bool = matches!(n.kind, NodeKind::BooleanOp { .. }); let is_text = matches!(n.kind, NodeKind::Text(_)); @@ -47,6 +68,7 @@ fn outlines(doc: &Document, n: &Node, fill_counts: fn(&figcraft_color::Paint) -> if matches!(n.kind, NodeKind::Group | NodeKind::Page { .. }) || (kids && !(n.kind.is_frame_like() && n.clip && has_fill)) || (is_text && n.has_visible_stroke()) + || alpha_fills(doc, n, fill_counts) { return None; } diff --git a/crates/svg/src/image_shadow_tests.rs b/crates/svg/src/image_shadow_tests.rs new file mode 100644 index 000000000..f5d3fbcfd --- /dev/null +++ b/crates/svg/src/image_shadow_tests.rs @@ -0,0 +1,59 @@ +//! Shadows of image fills in SVG follow what the image paints, like the renderer (#1289): a +//! picture with transparent pixels hides its drop shadows behind its alpha (not an image of its +//! box) and is an embedded render when it has inner shadows; an opaque one keeps its outline. + +use figcraft_color::{Color, ImageScaleMode, Paint}; +use figcraft_doc::{Document, Effect, EffectKind, ImageData, NodeId, NodeKind, Shadow}; + +use super::{SvgOptions, export_svg}; + +/// A 100 × 100 rectangle filled with a 100 × 100 red PNG: a circle on transparent pixels +/// (`circle`) or red all over, with `effect`. +fn scene(circle: bool, effect: EffectKind) -> (Document, NodeId) { + let mut pm = figcraft_render::Pixmap::new(100, 100).unwrap(); + for (i, px) in pm.data_mut().chunks_exact_mut(4).enumerate() { + let (x, y) = ((i % 100) as f64 + 0.5 - 50.0, (i / 100) as f64 + 0.5 - 50.0); + if !circle || x.hypot(y) <= 25.0 { + px.copy_from_slice(&[255, 0, 0, 255]); + } + } + let mut d = Document::new("svg image shadow"); + let p = d.first_page().unwrap(); + // Image ids key the renderer's shared caches: fixtures running in parallel need ids of their own. + let id = if circle { "svg-image-shadow-circle" } else { "svg-image-shadow-opaque" }; + let bytes = figcraft_render::encode_png(&pm).unwrap(); + d.images.insert(id.into(), ImageData { bytes: std::sync::Arc::new(bytes), width: 100, height: 100 }); + let n = d.create(p, NodeKind::Rectangle, "R", 10.0, 10.0, 100.0, 100.0).unwrap(); + let node = d.get_mut(n).unwrap(); + node.fills = vec![Paint::image(id, ImageScaleMode::Fill)]; + node.effects = vec![Effect { kind: effect, visible: true }]; + (d, n) +} + +fn export(d: &Document, id: NodeId) -> String { + export_svg(d, &[id], &SvgOptions::default()).unwrap() +} + +fn shadow() -> Shadow { + Shadow { color: Color::BLACK, offset: (6.0, 6.0), radius: 4.0, show_behind: false, ..Shadow::default() } +} + +#[test] +fn a_transparent_images_drop_shadow_hides_behind_its_alpha() { + let (d, n) = scene(true, EffectKind::DropShadow(shadow())); + let svg = export(&d, n); + assert!(svg.contains("in2=\"SourceAlpha\" operator=\"out\"") && !svg.contains("result=\"cover\""), "{svg}"); + let (d, n) = scene(false, EffectKind::DropShadow(shadow())); + let svg = export(&d, n); + assert!(svg.contains("in2=\"cover\" operator=\"out\""), "an opaque picture hides it behind its box: {svg}"); +} + +#[test] +fn a_transparent_images_inner_shadow_is_an_embedded_render() { + let (d, n) = scene(true, EffectKind::InnerShadow(shadow())); + let svg = export(&d, n); + assert!(!svg.contains(" { let whole = has(&|k| matches!(k, EffectKind::Texture(_))) || has(&|k| matches!(k, EffectKind::LayerBlur(b) if b.is_progressive())) || (has(&|k| matches!(k, EffectKind::Noise(_))) && has(&|k| matches!(k, EffectKind::LayerBlur(_)))) - // A group's inner shadows are cast by its layers' pixels, over them. - || (matches!(n.kind, NodeKind::Group) && has(&|k| matches!(k, EffectKind::InnerShadow(_)))); + // A group's inner shadows are cast by its layers' pixels, over them; an image fill's by + // its alpha. + || (has(&|k| matches!(k, EffectKind::InnerShadow(_))) && figcraft_render::shadow::inner_from_pixels(self.doc, n)); if !whole { return false; } @@ -1162,6 +1163,8 @@ mod effects_tests; #[cfg(test)] mod group_effects_tests; #[cfg(test)] +mod image_shadow_tests; +#[cfg(test)] mod mask_export_tests; #[cfg(test)] mod raster_fx_tests; diff --git a/docs/roadmap.md b/docs/roadmap.md index cc24f8597..3ff0d37f4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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 (shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-27 | minor | Bounded attached MCP replies (M8, #1113): `figcraft-cli mcp --attach` read each reply from the app's control port with no size limit, so a process listening on that port could make it buffer until memory ran out. Replies are now read up to 512 MiB (an app window capture is at most 256 MiB as base64); reading stops at the first byte past that, the connection is closed and the call is reported like any other lost reply, and the next call reconnects. Regression tests in the MCP backend tests. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 687d39f1b..98167a692 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; 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; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; 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, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; 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; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; 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, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -589,7 +589,8 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text; inner shadows over the fills and under the children and strokes; drop shadows hidden behind what - the layer covers, translucent fills included, unless shown behind transparent areas; layer blur; background blur + the layer covers, translucent fills included, unless shown behind transparent areas, and images + with transparent pixels cast, hide and shade their shadows by their alpha; layer blur; background blur that sees the whole backdrop, inside clipping frames, faded groups and masks too; both blurs uniform or progressive (the radius ramps between two points, dragged on the canvas); noise in mono, duo and multi colour with grain size, density and blend mode; texture with clip to shape; @@ -645,6 +646,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | | 2026-10-24 | minor | Boards (area 22, weight 0): a shared timer and voting sessions (M12); ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; weighted numbers unchanged | From 3d5185c2ab972d3d4a6665cdd4c1fa0398300f3f Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 16:14:40 +0700 Subject: [PATCH 13/30] M0.9: web wasm back under the 24 MiB gate (#1472) (#1511) The browser build's wasm was 25,982,540 bytes, past the gate raised for 0.12.1. It is now 23,915,213 bytes (22.8 MiB), 1.2 MiB under the restored 24 MiB gate: - Catalog rows keep their plural forms behind a reference: Text is two words, not room for four strings, across ~52,000 baked-in rows (-454 KB). - The browser build compiles with wasm SIMD (apps/figcraft-web/.cargo/config.toml, wasm-opt --enable-simd), so egui's glyph rasterizer leaves out its scalar SIMD fallback (-1.6 MB). The plugin workspace is built from the repository root and stays without SIMD. - Revert the gate raise (cb227c97); the hosting guide names the WebAssembly SIMD requirement. --- Cargo.toml | 2 +- ROADMAP.md | 3 ++- apps/figcraft-web/.cargo/config.toml | 6 ++++++ apps/figcraft-web/index.html | 2 +- crates/ui-egui/build.rs | 2 +- crates/ui-egui/src/field_tests.rs | 4 ++-- crates/ui-egui/src/i18n.rs | 27 +++++++++++++++------------ crates/ui-egui/src/i18n/tests.rs | 20 ++++++++++++++------ docs/releasing.md | 4 +++- docs/roadmap.md | 3 ++- docs/translating.md | 2 +- packaging/web/README.md | 4 ++-- packaging/web/package.sh | 4 +--- 13 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 apps/figcraft-web/.cargo/config.toml diff --git a/Cargo.toml b/Cargo.toml index 2bf5da744..46e48b9ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,7 +136,7 @@ lto = "thin" codegen-units = 4 # The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size: -# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB). +# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB). # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" diff --git a/ROADMAP.md b/ROADMAP.md index 484b6dd30..5827a75db 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-29 · **Change:** minor (shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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-29 · **Change:** minor (the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | | 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | | 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged | diff --git a/apps/figcraft-web/.cargo/config.toml b/apps/figcraft-web/.cargo/config.toml new file mode 100644 index 000000000..ebffd7002 --- /dev/null +++ b/apps/figcraft-web/.cargo/config.toml @@ -0,0 +1,6 @@ +# The browser build (trunk runs cargo from this folder; packaging/web/package.sh). With simd128 the +# glyph rasterizer's SIMD code compiles for wasm SIMD instead of its much larger scalar fallback: +# about 1.6 MB less wasm. Every current browser has wasm SIMD (Safari since 16.4). The plugin +# workspace is built from the repository root, so its modules stay without SIMD for the plugin runtime. +[target.wasm32-unknown-unknown] +rustflags = ["-Ctarget-feature=+simd128"] diff --git a/apps/figcraft-web/index.html b/apps/figcraft-web/index.html index f610aa813..1915a9a20 100644 --- a/apps/figcraft-web/index.html +++ b/apps/figcraft-web/index.html @@ -4,7 +4,7 @@ FigCraft - +