Merge pull request #1143 from Guillergood/feat/penpot-import-export

M6: import and export Penpot 2.x files offline
This commit is contained in:
BFlat
2026-10-29 14:20:14 +07:00
committed by GitHub
23 changed files with 10435 additions and 28 deletions
Generated
+1
View File
@@ -1777,6 +1777,7 @@ name = "figcraft-format"
version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-layout",
"figcraft-pathops",
"flate2",
"image",
+29 -5
View File
@@ -11,7 +11,13 @@ use serde_json::{Value, json};
use crate::{CliError, CliResult};
pub fn load(path: &str) -> CliResult<Document> {
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<Vec<u8>> {
Ok(bytes)
}
fn penpot_bytes(doc: &Document) -> CliResult<Vec<u8>> {
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<Vec<u8>> {
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<ExitCode> {
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<ExitCode> {
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
+3 -3
View File
@@ -17,7 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
figcraft-cli export <file> -o <out> [--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 <in> <out>
figcraft-cli render <file> -o <out.png> [--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 <out> directory, or one file when
<out> has an image extension or --node is given (`fig`, or an <out> 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 <out> 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)
+34
View File
@@ -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");
+12 -4
View File
@@ -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<CommandSpec> {
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 `<name>.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 `<name>.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<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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
),
+22 -5
View File
@@ -75,12 +75,14 @@ pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option<figcraft_render::Pixma
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
}
fn export_penpot(s: &mut Session, p: &Value) -> Result<Value> {
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<u8>, Vec<String>)> {
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<u8>, Vec<String>)> {
figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string()))
+369
View File
@@ -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<Result<figcraft_forma
}))
}
/// Turn Penpot raw SVG placeholders into editable FigCraft SVG content, using the
/// normal SVG importer. The parser (figcraft-format) only stores bounded XML so it
/// does not need to depend on the SVG crate; the engine does this second pass.
/// Any failure leaves the original placeholder and XML data intact for later use.
fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec<String>) {
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::<Vec<_>>();
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<String> = installed.iter().map(|s| s.trim().to_lowercase()).collect();
let mut families: BTreeMap<String, BTreeSet<String>> = 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::<String>();
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<String>> = 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::<Value>(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<Value> = 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::<Vec<_>>()
})
})
.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::<Vec<_>>()})
})
.collect::<Vec<_>>();
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<String> {
// An uninitialised font registry cannot prove a family is unavailable.
if families.is_empty() {
return Vec::new();
}
let available: BTreeSet<String> = families.iter().map(|s| s.trim().to_lowercase()).collect();
let mut missing: BTreeMap<String, BTreeSet<String>> = 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::<Vec<_>>()
.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<Option<Value>> {
}
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#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="20" height="20" fill="red"/></svg>"#;
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(), "<svg><broken".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!(layer.children.is_empty());
assert_eq!(layer.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("not-expanded"));
assert!(!warnings.is_empty());
}
#[test]
fn font_inventory_reports_missing_families_without_rewriting_text() {
let mut doc = Document::new("Font inventory");
let page = doc.first_page().unwrap();
let mut text = TextData::new("Example");
text.style.font_family = "Definitely Never Installed V10 Font Q7".into();
doc.create(page, NodeKind::Text(text), "Caption", 0.0, 0.0, 100.0, 24.0).unwrap();
let report = font_report_with_families(&doc, &["Inter".into()]);
assert_eq!(report["missing"], 1);
assert_eq!(report["fonts"][0]["family"], "Definitely Never Installed V10 Font Q7");
assert_eq!(report["fonts"][0]["layers"][0], "Caption");
}
}
+11 -5
View File
@@ -88,9 +88,9 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
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<Value> {
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)
@@ -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();
+2
View File
@@ -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;
+152
View File
@@ -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<Layer> {
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);
}
}
+1
View File
@@ -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 }
+1
View File
@@ -19,6 +19,7 @@
pub mod fig;
pub mod library;
pub mod penpot;
pub mod rest;
pub mod sketch;
pub mod thumbnail;
File diff suppressed because it is too large Load Diff
@@ -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<Cursor<&[u8]>>, warnings: &mut Vec<String>, budget: &mut InflateBudget) {
let names: Vec<String> = 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"));
}
}
File diff suppressed because it is too large Load Diff
+893
View File
@@ -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<String, Value> {
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<String, Value>, 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<String, Value>, Vec<u8>) {
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<String> = e.keys().cloned().collect();
let ids_after: BTreeSet<String> = 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<String> = 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<u8> {
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<String> = 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<u8> {
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<Value> =
(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<u8>)> = 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);
}
+365
View File
@@ -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<u32>) -> Option<Trigger> {
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<OverlayPosition> {
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<PenpotActionKind> {
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<String>,
/// 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<String>)> {
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<String>) -> Vec<PenpotInteraction> {
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<String>) {
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<Vec<Value>> {
let raw = node.plugin_data.get("penpot.interactions")?;
serde_json::from_str::<Value>(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<String, NodeId>, warnings: &mut Vec<String>) {
let mut mapped = 0usize;
let mut missing = 0usize;
let mut updates: Vec<(NodeId, Vec<Interaction>)> = 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<String, NodeId>, warnings: &mut Vec<String>) -> Vec<Flow> {
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);
}
}
+1
View File
@@ -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"
+1 -1
View File
@@ -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| {
+6 -3
View File
@@ -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.
+10 -2
View File
@@ -64,7 +64,7 @@ page 1 "Screens": 380 nodes, 4 top-level
…
```
### `export <file> -o <out> [--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 <file> -o <out> [--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]`
- `<out>` 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 `<out>` 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 `<out>`'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 <file> -o <out.png> [--scale N] [--page NAME]`
+107
View File
@@ -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
```