diff --git a/Cargo.lock b/Cargo.lock index 122fac45..8770f7be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1777,6 +1777,7 @@ name = "figcraft-format" version = "0.12.1" dependencies = [ "figcraft-doc", + "figcraft-layout", "figcraft-pathops", "flate2", "image", diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 8e689ec0..7b822646 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)) { @@ -237,12 +252,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|xml|fig)")))? - } + Some(f) => image_format(&f.to_ascii_lowercase()) + .ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig|penpot)")))?, None => image_format(&ext(out)).unwrap_or(ExportFormat::Png), }; let target = node.map(|q| find_node(&doc, page, q)).transpose()?; @@ -321,6 +340,11 @@ fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &fig pub fn convert(input: &str, out: &str) -> CliResult { let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?; refuse_dropping_images(input, out, loaded.images_past_budget)?; + if ext(input) == "penpot" { + for note in &loaded.issues { + eprintln!("note: {note}"); + } + } let doc = loaded.doc; let page = doc .current_page diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 971b3905..905a588e 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|xml|fig] [--scale N] [--page NAME] [--node ID|NAME] + figcraft-cli export -o [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore] figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] @@ -33,11 +33,11 @@ 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), .sketch (Sketch 48 or newer, best effort), .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); 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 1db451ba..ae00ceb7 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 option_terminator_allows_dash_prefixed_filenames() { let dir = tmp("option-terminator"); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 173507a6..bb1c7199 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -26,9 +26,10 @@ use export::add_member; pub(super) use export::export_setting; pub use export::{ ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes, - frames_pdf, jobs_for, safe_file_name, + frames_pdf, jobs_for, penpot_bytes, safe_file_name, }; use export::{export, export_frames_pdf, export_layers, pdf_bytes}; +use load::font_report; #[cfg(test)] pub(crate) use load::{installed_family, sketch_line}; pub use load::{load, load_bytes, load_path}; @@ -75,7 +76,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), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)", + "{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), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .penpot, .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig, .penpot and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)", false, open ), @@ -86,6 +87,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", @@ -110,7 +118,7 @@ pub fn specs() -> Vec { spec!( "file.export", "Export", - "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), 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), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}", + "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), 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 archive, with warnings for unsupported content), 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), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}", false, export ), @@ -144,7 +152,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, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: 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, .fig and .sketch pages are appended with their variables and flows (REST, .fig, .penpot and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", true, import ), diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs index 005afe6f..68e003d4 100644 --- a/crates/engine/src/cmd/file/export.rs +++ b/crates/engine/src/cmd/file/export.rs @@ -75,12 +75,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); @@ -127,6 +129,21 @@ pub(super) 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())) diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 54542eec..4e596bc6 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -1,3 +1,5 @@ +use std::collections::{BTreeMap, BTreeSet}; + use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Result, StyleKind, TextStyle, Value, bad, get, json}; /// The recovery copy at `path` as a document with unsaved changes and no path. @@ -83,6 +85,19 @@ pub(super) 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)?; @@ -351,6 +366,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. +pub(super) 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) { @@ -430,3 +688,114 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { } Ok(Some(v)) } + +#[cfg(test)] +mod penpot_font_warning_tests { + use super::*; + use figcraft_doc::{StyleRun, TextData}; + + #[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::*; + use figcraft_doc::TextData; + + #[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(), " 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]), @@ -137,7 +137,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; @@ -183,7 +183,7 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result { } Ok(out) } - "figcraft" | "json" | "fig" | "sketch" => { + "figcraft" | "json" | "fig" | "sketch" | "penpot" => { let resolver = path.as_deref().map(rest_images); let images: figcraft_format::rest::ImageResolver = match &resolver { Some(r) => r, @@ -193,8 +193,14 @@ pub(super) 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) diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index ec1a56e9..1cd83678 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -187,6 +187,23 @@ fn contents_only_leaves_out_overlapping_layers() { assert_eq!(pixels(&data).get_pixel(75, 75).0, [0, 0, 255, 255], "a layer's own setting is honoured"); } +#[test] +fn penpot_v20_export_limits_shadows_in_all_directions() { + let mut session = Session::new(); + for (index, offset, size) in + [((0.0, 0.0), (-12.0, -7.0), (112, 107)), ((200.0, 0.0), (13.0, -9.0), (113, 109)), ((400.0, 0.0), (-3.0, 14.0), (103, 114))] + { + let r = rect(&mut session, index.0, index.1, 100.0, 100.0, "#FF0000", None); + shadow(&mut session, r, offset, 0.0, 0.0); + assert_eq!(png_size(&export(&mut session, json!({"ids":[r]}))), size, "a sharp offset shadow must extend only the touched export edges"); + assert_eq!( + png_size(&export(&mut session, json!({"ids":[r], "includeBoundingBox": true}))), + (100, 100), + "explicit bounding-box exports must not grow due to a shadow" + ); + } +} + #[test] fn what_an_export_covers() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8335abb..998bae24 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -385,6 +385,8 @@ mod pen_end_tests; #[cfg(test)] mod pen_overlay_tests; #[cfg(test)] +mod penpot_export_tests; +#[cfg(test)] mod pixel_preview_tests; #[cfg(test)] mod placed_image_size_tests; diff --git a/crates/engine/src/penpot_export_tests.rs b/crates/engine/src/penpot_export_tests.rs new file mode 100644 index 00000000..0e4b15c7 --- /dev/null +++ b/crates/engine/src/penpot_export_tests.rs @@ -0,0 +1,152 @@ +use base64::Engine as _; +use figcraft_doc::{Document, NodeKind}; +use serde_json::{Value, json}; + +use crate::{EngineError, Session}; + +fn id(v: &Value) -> 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 b144465f..e4a379e4 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 f415566f..52f12075 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 sketch; pub mod thumbnail; diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs new file mode 100644 index 00000000..554c23b1 --- /dev/null +++ b/crates/format/src/penpot.rs @@ -0,0 +1,6515 @@ +//! 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) { + node.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(); + 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 { + 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 !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)?; + } + } + } + // 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 || 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( + layers.warnings, + format!( + "Penpot orphan layer '{}' has no reachable parent; attached to page", + string(shape, "name").unwrap_or(id).chars().take(80).collect::() + ), + ); + 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 && !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)?; + } + } + } + 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, 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()); + } + } + 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) + }) +} + +/// 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, + 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 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(); + 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); + } + } + } + } + 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 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 { + 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)); + } + 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 { + 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) +} + +/// 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, +) -> (NodeKind, Option, Option) { + let mut path_box = None; + let mut vector_source = None; + let kind = match typ { + "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, + "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 + } + }; + (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, + }; + 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)); + } +} + +/// 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")); + 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(self.shapes, v), + }), + }; + if let Some(source) = source { + fills = self.layer_paints(&source, "fills"); + } + 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( + self.warnings, + format!("Penpot SVG paint-server url(#{reference}) cannot be represented as a native gradient; original SVG attributes retained"), + ); + fills + } + } + } + + /// 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 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" { + 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 { + node.layout = layout; + } + } + } + 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)) + { + layout.reverse_z = true; + children.reverse(); + } + let cells = grid_cells(v); + for child in children { + self.add_layer(&child, created, abs, cells.get(child.as_str()).copied(), depth + 1)?; + } + // `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(()) + } +} + +/// 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_eq!(page.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 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"; + 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 00000000..d0989680 --- /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 00000000..1de09e89 --- /dev/null +++ b/crates/format/src/penpot/export.rs @@ -0,0 +1,1826 @@ +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, .. } = &node.kind else { return page_json }; + let guides = &node.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 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))); + 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() { + 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!(bool_type(op))); + } + 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 + )); + } + self.item_fields(node, parent_node, &mut fields); + if typ == "text" + && let NodeKind::Text(text) = &node.kind + { + self.text_fields(node, text, &mut fields); + } + 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) + } + + /// 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") { + 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, anchor } => { + if *anchor != figcraft_doc::proto::OverlayAnchor::Screen { + self.warn(format!("Overlay anchor on '{}' was approximated relative to the screen", node.name)); + } + 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() + } +} + +/// 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", + } +} diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs new file mode 100644 index 00000000..2350f77b --- /dev/null +++ b/crates/format/src/penpot/export_tests.rs @@ -0,0 +1,893 @@ +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 { + anchor: Default::default(), + 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")); + if let Action::OpenOverlay { anchor, .. } = &mut doc.node_mut(button).unwrap().interactions[1].actions[0] { + *anchor = figcraft_doc::proto::OverlayAnchor::TriggerOutside; + } + let (_, warnings) = export(&doc).unwrap(); + assert!(warnings.iter().any(|warning| warning.contains("Overlay anchor") && warning.contains("screen")), "{warnings:?}"); +} + +#[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); + doc.node_mut(p).unwrap().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 guides = &back.node(back.pages[0]).unwrap().guides; + 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 00000000..44f8b419 --- /dev/null +++ b/crates/format/src/penpot/prototype.rs @@ -0,0 +1,365 @@ +//! 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, anchor: Default::default() } + } + 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, anchor, .. } => { + Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor } + } + 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. + 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. + 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 +} + +#[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/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 639031af..844dbb27 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2039,6 +2039,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/app/files.rs b/crates/ui-egui/src/app/files.rs index 532cf8ab..06f93e30 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -75,7 +75,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", "sketch"] + &["penpot", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; self.pick_file(title, exts, true, |app, file| { diff --git a/crates/ui-egui/src/open.rs b/crates/ui-egui/src/open.rs index 47b5d575..fb6b1ba2 100644 --- a/crates/ui-egui/src/open.rs +++ b/crates/ui-egui/src/open.rs @@ -4,17 +4,20 @@ use super::services::{ReadFuture, unreadable}; use super::{OsStr, Path, theme}; /// The file types File ▸ Open and a drop on the home screen open. -pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; +pub(crate) const OPEN_EXTENSIONS: &[&str] = + &["figcraft", "fig", "penpot", "sketch", "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`, `.sketch`, file JSON) rather than an image or SVG: +/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.penpot`, `.sketch`, 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", "sketch", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension() + .and_then(OsStr::to_str) + .is_some_and(|e| ["figcraft", "fig", "penpot", "sketch", "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/cli.md b/docs/cli.md index 58436b3e..b3a78c52 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -64,7 +64,7 @@ page 1 "Screens": 380 nodes, 4 top-level … ``` -### `export -o [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]` +### `export -o [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]` - `` 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. @@ -86,6 +86,13 @@ page 1 "Screens": 380 nodes, 4 top-level approximated), `exact` (left out) or `ignore` (no notes: overlapping layer opacity is dropped, even-odd fills keep `fillType="evenOdd"`, diamond gradients are left out). See `docs/file-format-parity.md`. +- `--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/XML exports with visible descendants more than 256 levels below an exported root fail with @@ -117,7 +124,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/penpot.md b/docs/penpot.md new file mode 100644 index 00000000..ecdda4dc --- /dev/null +++ b/docs/penpot.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 +```