mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
M6: add bounded offline Penpot import and export
This commit is contained in:
Generated
+1
@@ -1596,6 +1596,7 @@ name = "figcraft-format"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
"flate2",
|
||||
"image",
|
||||
|
||||
+4
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (M6: offline Penpot import/export using the existing document model, bounded archive and token parsing) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-25** · M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged.
|
||||
|
||||
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
|
||||
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
|
||||
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
|
||||
@@ -329,6 +331,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. |
|
||||
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
|
||||
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
|
||||
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
|
||||
|
||||
@@ -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)) {
|
||||
@@ -235,12 +250,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|fig)")))?
|
||||
}
|
||||
Some(f) => image_format(&f.to_ascii_lowercase())
|
||||
.ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig|penpot)")))?,
|
||||
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
|
||||
};
|
||||
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
|
||||
|
||||
@@ -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|fig] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
@@ -33,11 +33,12 @@ Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): te
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
|
||||
per top-level frame into the <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 to import into a Penpot team); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
||||
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
|
||||
@@ -13,6 +13,40 @@ fn tmp(name: &str) -> std::path::PathBuf {
|
||||
d
|
||||
}
|
||||
|
||||
/// Conversion and image commands must report the Penpot loader's notes too.
|
||||
#[test]
|
||||
fn penpot_import_notes_reach_convert_export_and_render() {
|
||||
use figcraft_doc::proto::{Action, Easing, Interaction, Transition, Trigger};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
let d = tmp("penpot-notes");
|
||||
let mut doc = Document::new("Prototype");
|
||||
let page = doc.first_page().unwrap();
|
||||
let home = doc.create(page, NodeKind::Frame, "Home", 0.0, 0.0, 40.0, 40.0).unwrap();
|
||||
let dest = doc.create(page, NodeKind::Frame, "Next", 60.0, 0.0, 40.0, 40.0).unwrap();
|
||||
doc.node_mut(home).unwrap().interactions.push(Interaction {
|
||||
trigger: Trigger::OnClick,
|
||||
actions: vec![Action::Navigate { dest, preserve_scroll: false }],
|
||||
transition: Transition::Instant,
|
||||
duration_ms: 0,
|
||||
easing: Easing::Linear,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let input = d.join("source.penpot");
|
||||
std::fs::write(&input, figcraft_format::penpot::export(&doc).unwrap().0).unwrap();
|
||||
for (command, name) in [("convert", "out.figcraft"), ("export", "out.svg"), ("render", "out.png")] {
|
||||
let mut cmd = bin();
|
||||
cmd.arg(command).arg(&input);
|
||||
if command != "convert" {
|
||||
cmd.arg("-o");
|
||||
}
|
||||
let out = cmd.arg(d.join(name)).output().unwrap();
|
||||
let notes = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{command}: {notes}");
|
||||
assert!(notes.contains("note: Imported 1 Penpot prototype connection"), "{command}: {notes}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_exit_2_without_panicking() {
|
||||
for args in [
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
use figcraft_ui_egui::dialogs::window_failed as message;
|
||||
|
||||
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
|
||||
const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.";
|
||||
const DRIVER_HINT: &str = if cfg!(windows) {
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:"
|
||||
} else {
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
|
||||
};
|
||||
|
||||
/// Other startup tests can change the process-wide language while this message is built.
|
||||
fn contains_hint(text: &str, key: &str) -> bool {
|
||||
use figcraft_ui_egui::i18n::{Text, languages};
|
||||
text.contains(key)
|
||||
|| languages().iter().flat_map(|lang| lang.ui).any(|(k, value)| *k == key && matches!(value, Text::Plain(hint) if text.contains(hint)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
@@ -15,9 +28,9 @@ fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
#[test]
|
||||
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("graphics driver"), "{text}");
|
||||
assert!(contains_hint(&text, DRIVER_HINT), "{text}");
|
||||
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
|
||||
assert!(!text.contains("unsetting"), "{text}");
|
||||
assert!(!contains_hint(&text, BACKEND_HINT), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -30,6 +43,6 @@ fn on_windows_it_shows_how_to_start_with_opengl() {
|
||||
#[test]
|
||||
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
|
||||
let text = message(ERROR, true);
|
||||
assert!(text.contains("unsetting"), "{text}");
|
||||
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
|
||||
assert!(contains_hint(&text, BACKEND_HINT), "{text}");
|
||||
assert!(!contains_hint(&text, DRIVER_HINT), "the chosen backend is the likelier cause: {text}");
|
||||
}
|
||||
|
||||
+410
-13
@@ -1,5 +1,7 @@
|
||||
//! File menu: documents, save/open, import/export, versions.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<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)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .penpot, .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
false,
|
||||
open
|
||||
),
|
||||
@@ -53,6 +55,13 @@ pub fn specs() -> Vec<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",
|
||||
@@ -70,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x .penpot archive, with `warnings` for what Penpot cannot represent), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -98,7 +107,7 @@ pub fn specs() -> Vec<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 and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, .penpot, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
true,
|
||||
import
|
||||
),
|
||||
@@ -536,6 +545,19 @@ fn load_with(
|
||||
.notes;
|
||||
Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false))
|
||||
}
|
||||
"penpot" => {
|
||||
let (mut doc, mut notes) = figcraft_format::penpot::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
expand_penpot_raw_svgs(&mut doc, &mut notes);
|
||||
// Penpot imports stay offline: referenced fonts must be installed locally.
|
||||
// The format reader stays platform-agnostic. Font availability is checked here,
|
||||
// where FigCraft's system/embedded font registry is accessible. Never replace
|
||||
// the original family in the document: it can resolve after installation.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let families = figcraft_text::family_names();
|
||||
let mut font_notes = missing_penpot_font_warnings(&doc, &families);
|
||||
font_notes.append(&mut notes); // Show missing-font details first in the UI toast.
|
||||
Ok((figcraft_format::Loaded { issues: font_notes, ..doc.into() }, true))
|
||||
}
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => {
|
||||
let mut doc = Document::new(file_stem(name_hint));
|
||||
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
|
||||
@@ -572,6 +594,249 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<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.
|
||||
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) {
|
||||
@@ -722,9 +987,9 @@ 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]),
|
||||
@@ -745,7 +1010,7 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>)
|
||||
let data = data.split_once(',').map_or(data, |(_, b)| b);
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
|
||||
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
|
||||
let warnings = rest || ext(&name) == "svg";
|
||||
let warnings = rest || matches!(ext(&name).as_str(), "svg" | "penpot");
|
||||
let summary = import_summary(&loaded.doc);
|
||||
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
@@ -1028,12 +1293,14 @@ pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option<figcraft_render::Pixma
|
||||
|
||||
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);
|
||||
@@ -1075,6 +1342,21 @@ 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()))
|
||||
@@ -1331,7 +1613,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"figcraft" | "json" | "fig" => {
|
||||
"figcraft" | "json" | "fig" | "penpot" => {
|
||||
let resolver = path.as_deref().map(rest_images);
|
||||
let images: figcraft_format::rest::ImageResolver = match &resolver {
|
||||
Some(r) => r,
|
||||
@@ -1341,8 +1623,14 @@ fn import(s: &mut Session, p: &Value) -> Result<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)
|
||||
@@ -1768,3 +2056,112 @@ mod staging_write_tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod penpot_font_warning_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn font_report_explains_custom_and_google_provenance() {
|
||||
let mut doc = Document::new("External font refs");
|
||||
let page = doc.first_page().unwrap();
|
||||
let text = TextData::new("Playball");
|
||||
let id = doc.create(page, NodeKind::Text(text), "Custom caption", 0., 0., 120., 30.).unwrap();
|
||||
doc.node_mut(id).unwrap().plugin_data.insert(
|
||||
"penpot.customFontRefs".into(),
|
||||
r#"[{"id":"custom-123abc","family":"Playball","variants":[{"weight":400,"italic":false}]}]"#.into(),
|
||||
);
|
||||
let report = font_report_with_families(&doc, &[]);
|
||||
assert_eq!(report["externalPenpotFontRefs"][0]["source"], "penpot-custom");
|
||||
assert_eq!(report["externalPenpotFontRefs"][0]["family"], "Playball");
|
||||
assert_eq!(report["externalPenpotFontRefs"][0]["referenceKind"], "custom-original-unverified");
|
||||
assert!(report["externalPenpotFontRefs"][0]["originNote"].as_str().unwrap().contains("not proof"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_only_missing_families_without_modifying_original_text() {
|
||||
let mut doc = Document::new("Penpot fonts");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut text = TextData::new("hello world");
|
||||
text.style.font_family = "Rare Licensed Family".into();
|
||||
doc.create(page, NodeKind::Text(text), "Headline", 0.0, 0.0, 200.0, 30.0).unwrap();
|
||||
let warnings = missing_penpot_font_warnings(&doc, &["Inter".into(), "Source Sans Pro".into()]);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("Rare Licensed Family"));
|
||||
assert!(warnings[0].contains("Headline"));
|
||||
assert_eq!(
|
||||
doc.nodes().find(|n| n.name == "Headline").and_then(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.style.font_family.as_str()),
|
||||
_ => None,
|
||||
}),
|
||||
Some("Rare Licensed Family")
|
||||
);
|
||||
assert!(missing_penpot_font_warnings(&doc, &["RARE LICENSED FAMILY".into()]).is_empty());
|
||||
assert!(missing_penpot_font_warnings(&doc, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completely_styled_text_does_not_report_an_unused_base_font() {
|
||||
let mut doc = Document::new("Run fonts");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut t = TextData::new("AB");
|
||||
t.style.font_family = "Unused Base".into();
|
||||
let mut run = t.style.clone();
|
||||
run.font_family = "Available Font".into();
|
||||
t.runs.push(StyleRun { start: 0, end: 2, style: run });
|
||||
doc.create(page, NodeKind::Text(t), "Full run", 0.0, 0.0, 100.0, 30.0).unwrap();
|
||||
assert!(missing_penpot_font_warnings(&doc, &["Available Font".into()]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod penpot_svg_v10_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expands_inline_svg_into_editable_shapes_without_discarding_source() {
|
||||
let mut doc = Document::new("SVG fixture");
|
||||
let page = doc.first_page().unwrap();
|
||||
let id = doc.create(page, NodeKind::Frame, "SVG ornament", 10.0, 20.0, 100.0, 50.0).unwrap();
|
||||
let svg = r#"<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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
pub mod fig;
|
||||
pub mod library;
|
||||
pub mod penpot;
|
||||
pub mod rest;
|
||||
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
@@ -0,0 +1,889 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use figcraft_doc::color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_doc::geom::{Affine, Point, Shape};
|
||||
use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, Blur, Collection, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId, NodeKind,
|
||||
Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue, VectorNetwork,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{ROOT_ID, export, import};
|
||||
|
||||
fn entries(bytes: &[u8]) -> BTreeMap<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 {
|
||||
dest: menu,
|
||||
position: OverlayPosition::Center,
|
||||
close_on_outside: true,
|
||||
background: Some(Color::BLACK),
|
||||
offset: None,
|
||||
}],
|
||||
transition: Transition::Dissolve,
|
||||
duration_ms: 200,
|
||||
easing: Easing::Linear,
|
||||
delay_ms: 0,
|
||||
},
|
||||
Interaction {
|
||||
trigger: Trigger::AfterDelay { ms: 1500 },
|
||||
actions: vec![Action::Back],
|
||||
transition: Transition::Instant,
|
||||
duration_ms: 0,
|
||||
easing: Easing::Linear,
|
||||
delay_ms: 0,
|
||||
},
|
||||
];
|
||||
if let NodeKind::Page { flows, .. } = &mut doc.node_mut(p).unwrap().kind {
|
||||
flows.push(Flow { name: "Main".into(), start: home, description: String::new() });
|
||||
}
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let items = shape(&e, "Go")["interactions"].as_array().unwrap().clone();
|
||||
assert_eq!(items[0]["destination"], shape(&e, "Next")["id"]);
|
||||
assert_eq!(items[0]["animation"]["animationType"], "slide");
|
||||
assert_eq!(items[1]["actionType"], "open-overlay");
|
||||
assert_eq!(items[2]["actionType"], "prev-screen");
|
||||
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
|
||||
assert_eq!(page_json["flows"].as_object().unwrap().values().next().unwrap()["startingFrame"], shape(&e, "Home")["id"]);
|
||||
|
||||
let go = back.node(named(&back, "Go")).unwrap();
|
||||
assert_eq!(go.interactions.len(), 3);
|
||||
assert!(matches!(go.interactions[0].actions[0], Action::Navigate { dest, preserve_scroll: true } if dest == named(&back, "Next")));
|
||||
assert_eq!(go.interactions[0].transition, Transition::SlideIn { direction: Direction::Right });
|
||||
assert_eq!((go.interactions[0].duration_ms, go.interactions[0].easing), (300, Easing::EaseOut));
|
||||
assert!(matches!(go.interactions[1].actions[0], Action::OpenOverlay { dest, close_on_outside: true, .. } if dest == named(&back, "Menu")));
|
||||
assert!(matches!(go.interactions[2].trigger, Trigger::AfterDelay { ms: 1500 }));
|
||||
let NodeKind::Page { flows, .. } = &back.node(back.pages[0]).unwrap().kind else { panic!() };
|
||||
assert_eq!(flows.len(), 1);
|
||||
assert_eq!(flows[0].start, named(&back, "Home"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_fills_write_media_and_storage_objects() {
|
||||
let mut doc = Document::new("Images");
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let (hash, _, _) = crate::add_image(&mut doc, png.into_inner()).unwrap();
|
||||
let p = page(&doc);
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, "Picture", 0.0, 0.0, 30.0, 20.0);
|
||||
doc.node_mut(id).unwrap().fills = vec![Paint::image(hash.clone(), ImageScaleMode::Fill)];
|
||||
let (back, e, bytes) = roundtrip(&doc);
|
||||
let fill = &shape(&e, "Picture")["fills"][0]["fillImage"];
|
||||
assert_eq!((fill["width"].as_u64(), fill["height"].as_u64(), fill["mtype"].as_str()), (Some(3), Some(2), Some("image/png")));
|
||||
let media = e.iter().find(|(k, _)| k.contains("/media/")).unwrap().1;
|
||||
assert_eq!(media["id"], fill["id"]);
|
||||
let object = &e[&format!("objects/{}.json", media["mediaId"].as_str().unwrap())];
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap();
|
||||
let blob = z.by_name(&format!("objects/{}.png", media["mediaId"].as_str().unwrap())).unwrap().size();
|
||||
assert_eq!(object["size"].as_u64(), Some(blob));
|
||||
assert_eq!(object["bucket"], "file-media-object");
|
||||
let n = back.node(named(&back, "Picture")).unwrap();
|
||||
assert!(matches!(&n.fills[0].kind, PaintKind::Image(img) if img.image == hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_penpot_ids_are_made_unique_per_page() {
|
||||
let mut doc = Document::new("Dupes");
|
||||
let p = page(&doc);
|
||||
for name in ["One", "Two"] {
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, name, 0.0, 0.0, 10.0, 10.0);
|
||||
doc.node_mut(id).unwrap().plugin_data.insert("penpot.id".into(), "dddddddd-dddd-4ddd-8ddd-dddddddddddd".into());
|
||||
}
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
assert_eq!(shape(&e, "One")["id"], "dddddddd-dddd-4ddd-8ddd-dddddddddddd");
|
||||
assert_ne!(shape(&e, "Two")["id"], shape(&e, "One")["id"]);
|
||||
assert_eq!(back.nodes().filter(|n| matches!(n.kind, NodeKind::Rectangle)).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_paints_and_effects_are_reported() {
|
||||
let mut doc = Document::new("Lossy");
|
||||
let p = page(&doc);
|
||||
let id = add(&mut doc, p, NodeKind::Rectangle, "Lossy", 0.0, 0.0, 10.0, 10.0);
|
||||
let mut hidden = Paint::solid(Color::BLACK);
|
||||
hidden.visible = false;
|
||||
doc.node_mut(id).unwrap().fills = vec![hidden];
|
||||
let (_, warnings) = export(&doc).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("Hidden fills")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_coloured_run_does_not_recolour_the_rest_of_the_text() {
|
||||
let mut text = TextData::new("plain red");
|
||||
text.style.fills = Some(vec![Paint::solid(Color::BLACK)]);
|
||||
let mut red = text.style.clone();
|
||||
red.fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
|
||||
text.runs.push(StyleRun { start: 6, end: 9, style: red });
|
||||
assert_eq!(super::uniform_text_fills(&text), None);
|
||||
text.runs[0].start = 0;
|
||||
assert!(super::uniform_text_fills(&text).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_grids_and_guides_round_trip() {
|
||||
let mut doc = Document::new("Grids");
|
||||
let p = page(&doc);
|
||||
if let NodeKind::Page { guides, .. } = &mut doc.node_mut(p).unwrap().kind {
|
||||
guides.push(figcraft_doc::Guide { vertical: true, offset: 64.0 });
|
||||
}
|
||||
let frame = add(&mut doc, p, NodeKind::Frame, "Board", 100.0, 50.0, 400.0, 300.0);
|
||||
{
|
||||
let n = doc.node_mut(frame).unwrap();
|
||||
n.grids = vec![
|
||||
figcraft_doc::LayoutGrid { count: 12, gutter: 16.0, offset: 24.0, ..figcraft_doc::LayoutGrid::columns() },
|
||||
figcraft_doc::LayoutGrid { size: 8.0, ..figcraft_doc::LayoutGrid::default() },
|
||||
];
|
||||
n.guides = vec![figcraft_doc::Guide { vertical: false, offset: 10.0 }];
|
||||
}
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
let grids = &shape(&e, "Board")["grids"];
|
||||
assert_eq!(grids[0]["type"], "column");
|
||||
assert_eq!(grids[0]["params"]["size"], 12);
|
||||
assert_eq!(grids[1]["params"]["size"], 8);
|
||||
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
|
||||
let guides: Vec<&Value> = page_json["guides"].as_object().unwrap().values().collect();
|
||||
assert!(guides.iter().any(|g| g["axis"] == "x" && g["position"] == 64 && g.get("frameId").is_none()));
|
||||
assert!(guides.iter().any(|g| g["axis"] == "y" && g["position"] == 60 && g["frameId"] == shape(&e, "Board")["id"]));
|
||||
let n = back.node(named(&back, "Board")).unwrap();
|
||||
assert_eq!((n.grids[0].count, n.grids[0].gutter, n.grids[0].offset), (12, 16.0, 24.0));
|
||||
assert_eq!(n.grids[1].size, 8.0);
|
||||
let NodeKind::Page { guides, .. } = &back.node(back.pages[0]).unwrap().kind else { panic!() };
|
||||
assert!(guides.iter().any(|g| g.vertical && g.offset == 64.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_svg_layers_go_back_as_svg_raw_without_their_expansion() {
|
||||
let mut doc = Document::new("Svg");
|
||||
let p = page(&doc);
|
||||
let raw = add(&mut doc, p, NodeKind::Frame, "Logo", 0.0, 0.0, 24.0, 24.0);
|
||||
add(&mut doc, raw, NodeKind::Frame, "Expanded", 0.0, 0.0, 24.0, 24.0);
|
||||
{
|
||||
let n = doc.node_mut(raw).unwrap();
|
||||
n.plugin_data.insert("penpot.id".into(), "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee".into());
|
||||
n.plugin_data.insert("penpot.svgRawStatus".into(), "expanded".into());
|
||||
n.plugin_data.insert("penpot.source.content".into(), r#"{"tag":"svg","attrs":{"viewBox":"0 0 24 24"},"content":[]}"#.into());
|
||||
}
|
||||
let (bytes, _) = export(&doc).unwrap();
|
||||
let e = entries(&bytes);
|
||||
let s = shape(&e, "Logo");
|
||||
assert_eq!(s["type"], "svg-raw");
|
||||
assert_eq!(s["content"]["tag"], "svg");
|
||||
assert_eq!(s["shapes"], json!([]));
|
||||
assert!(!e.values().any(|v| v["name"] == "Expanded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_children_beyond_the_declared_rows_get_implicit_rows() {
|
||||
let mut doc = Document::new("Overflow");
|
||||
let p = page(&doc);
|
||||
let frame = add(&mut doc, p, NodeKind::Frame, "Grid", 0.0, 0.0, 200.0, 200.0);
|
||||
for i in 0..5 {
|
||||
add(&mut doc, frame, NodeKind::Rectangle, &format!("Item {i}"), 0.0, 0.0, 10.0, 10.0);
|
||||
}
|
||||
doc.node_mut(frame).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 1, ..AutoLayout::default() });
|
||||
let (bytes, warnings) = export(&doc).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let e = entries(&bytes);
|
||||
let s = shape(&e, "Grid");
|
||||
assert_eq!(s["layoutGridRows"].as_array().unwrap().len(), 3);
|
||||
let filled = s["layoutGridCells"].as_object().unwrap().values().filter(|c| !c["shapes"].as_array().unwrap().is_empty()).count();
|
||||
assert_eq!(filled, 5);
|
||||
}
|
||||
|
||||
fn penpot_archive(shapes: &[Value]) -> Vec<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);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Penpot prototype interactions and flows, mapped to FigCraft's native prototype model.
|
||||
//!
|
||||
//! Penpot stores an interaction on the shape that fires it: an event type (`click`,
|
||||
//! `mouse-press`, `mouse-over`, `mouse-enter`, `mouse-leave`, `after-delay`), an action
|
||||
//! type (`navigate`, `open-overlay`, `toggle-overlay`, `close-overlay`, `prev-screen`,
|
||||
//! `open-url`), the destination shape and an optional animation. FigCraft models the same
|
||||
//! idea as an [`Interaction`] (a trigger plus a list of actions) and a [`Flow`] (a named
|
||||
//! starting frame on a page).
|
||||
//!
|
||||
//! A destination is a Penpot shape id, but a FigCraft action points at a node id. The
|
||||
//! importer therefore keeps the Penpot source of every interaction in the layer's metadata
|
||||
//! and reads it back once every page is in the document, when the Penpot ids can be
|
||||
//! resolved to node ids (see [`resolve_destinations`]).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_doc::color::Color;
|
||||
use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::warn;
|
||||
|
||||
/// Longest animation Penpot may declare, in milliseconds.
|
||||
const MAX_DURATION_MS: u32 = 600_000;
|
||||
/// Longest "after delay" wait, in milliseconds.
|
||||
const MAX_DELAY_MS: u32 = 600_000;
|
||||
/// Most interactions read from one shape.
|
||||
const MAX_INTERACTIONS: usize = 64;
|
||||
/// Most flows a page may declare (Penpot allows two).
|
||||
const MAX_FLOWS: usize = 8;
|
||||
/// Longest stored interaction source for one shape, in bytes.
|
||||
const MAX_INTERACTION_SOURCE: usize = 64 * 1024;
|
||||
|
||||
/// Penpot's `event-type` names, mapped to FigCraft's triggers.
|
||||
fn penpot_trigger(event: Option<&str>, delay_ms: Option<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 }
|
||||
}
|
||||
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, .. } => {
|
||||
Action::OpenOverlay { dest, position, close_on_outside, background, offset }
|
||||
}
|
||||
Action::SwapOverlay { .. } => Action::SwapOverlay { dest },
|
||||
Action::ChangeTo { .. } => Action::ChangeTo { dest },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a page's Penpot `flows` and turn them into FigCraft flows.
|
||||
///
|
||||
/// A Penpot flow names the frame the prototype starts at. FigCraft's flow needs a node id,
|
||||
/// so the Penpot frame id is resolved through `by_penpot_id`.
|
||||
pub(super) fn page_flows(doc: &Document, flows: &Value, by_penpot_id: &BTreeMap<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.
|
||||
warnings.push("A Penpot flow names a starting frame that is not in the file; it was skipped".into());
|
||||
continue;
|
||||
};
|
||||
// The player shows the top-level frame a starting frame sits in.
|
||||
let start = doc.top_level_frame(frame).unwrap_or(frame);
|
||||
let name = flow.get("name").and_then(Value::as_str).map(str::to_owned).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Flow".to_owned());
|
||||
out.push(Flow { name: name.chars().take(256).collect(), start, description: String::new() });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -21,7 +21,7 @@ pub mod width;
|
||||
mod width_tests;
|
||||
|
||||
use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2};
|
||||
use figcraft_geom::kurbo::{self, BezPath, Cap, Join, ParamCurveArclen, PathEl, PathSeg, Point, Shape, Vec2};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum PathOpsError {
|
||||
@@ -293,7 +293,17 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
|
||||
.with_start_cap(kcap(props.cap_start))
|
||||
.with_end_cap(kcap(props.cap_end));
|
||||
if let Some(pat) = width::dash_pattern(props) {
|
||||
style = style.with_dashes(0.0, pat);
|
||||
let dots = [props.cap_start, props.cap_end].into_iter().any(|c| matches!(kcap(c), Cap::Round));
|
||||
let pat: Vec<f64> = pat.into_iter().map(|d| if dots && d == 0.0 { 1e-3 } else { d }).collect();
|
||||
// Each subpath restarts the pattern. Budget both total length and per-subpath starts,
|
||||
// otherwise tiny round-capped dots can expand into hundreds of thousands of contours.
|
||||
let starts = path.elements().iter().filter(|el| matches!(el, PathEl::MoveTo(_))).count();
|
||||
let length: f64 = path.segments().map(|seg| seg.arclen(0.05)).sum();
|
||||
let period: f64 = pat.iter().sum();
|
||||
let estimated = (length / period + starts as f64) * pat.len() as f64;
|
||||
if period.is_finite() && period > 0.0 && estimated.is_finite() && estimated < width::MAX_DASHES as f64 {
|
||||
style = style.with_dashes(0.0, pat);
|
||||
}
|
||||
}
|
||||
kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05)
|
||||
}
|
||||
@@ -742,4 +752,43 @@ mod tests {
|
||||
let s = stroke_outline_of(&ring, &inside, true, Some(&nz));
|
||||
assert!(s.winding(material) != 0 && s.winding(hole) != 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_round_dashes_fall_back_before_expansion() {
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((10_000.0, 0.0));
|
||||
let dangerous = StrokeProps {
|
||||
weight: 2.0,
|
||||
dashes: vec![0.0, 0.02],
|
||||
cap_start: StrokeCap::Round,
|
||||
cap_end: StrokeCap::Round,
|
||||
align: StrokeAlign::Center,
|
||||
..Default::default()
|
||||
};
|
||||
let outlined = stroke_center(&line, 2.0, &dangerous);
|
||||
let solid = stroke_center(&line, 2.0, &StrokeProps { dashes: vec![], ..dangerous });
|
||||
assert!((outlined.area().abs() - solid.area().abs()).abs() < 0.01);
|
||||
assert!(outlined.elements().len() < 1000, "fallback must not emit hundreds of thousands of dots");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_length_round_dashes_draw_dots() {
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((100.0, 0.0));
|
||||
let round = StrokeProps {
|
||||
weight: 2.0,
|
||||
dashes: vec![0.0, 9.0],
|
||||
cap_start: StrokeCap::Round,
|
||||
cap_end: StrokeCap::Round,
|
||||
align: StrokeAlign::Center,
|
||||
..Default::default()
|
||||
};
|
||||
let dots = stroke_center(&line, 2.0, &round);
|
||||
let area = dots.area().abs();
|
||||
assert!((area - 12.0 * std::f64::consts::PI).abs() < 1.5, "{area}");
|
||||
let butt = StrokeProps { cap_start: StrokeCap::None, cap_end: StrokeCap::None, ..round };
|
||||
assert!(stroke_center(&line, 2.0, &butt).area().abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub use figcraft_geom::measure::{Polyline, polylines};
|
||||
/// straight runs.
|
||||
const SAMPLES_PER_SUBPATH: f64 = 128.0;
|
||||
/// The most dashes a dashed variable width stroke makes.
|
||||
const MAX_DASHES: usize = 20_000;
|
||||
pub(crate) const MAX_DASHES: usize = 20_000;
|
||||
|
||||
/// The nearest point of the stroke path to `p`: (subpath index, t by length, distance).
|
||||
pub fn nearest(lines: &[Polyline], p: Point) -> Option<(usize, f64, f64)> {
|
||||
|
||||
@@ -1970,6 +1970,7 @@ status = "draft — needs native review"
|
||||
"file.newFromTemplate" = "Nuevo desde plantilla"
|
||||
"file.open" = "Abrir"
|
||||
"file.openRecent" = "Abrir reciente"
|
||||
"file.fontReport" = "Inspeccionar fuentes del documento"
|
||||
"file.placeImage" = "Colocar imagen"
|
||||
"file.recover" = "Recuperar archivo"
|
||||
"file.rename" = "Cambiar nombre del archivo"
|
||||
|
||||
@@ -1251,7 +1251,7 @@ impl FigApp {
|
||||
let exts: &[&str] = if place {
|
||||
&["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]
|
||||
} else {
|
||||
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
|
||||
&["penpot", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
|
||||
};
|
||||
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
|
||||
self.pick_file(title, exts, true, |app, file| {
|
||||
@@ -1885,17 +1885,17 @@ impl FigApp {
|
||||
}
|
||||
|
||||
/// The file types File ▸ Open and a drop on the home screen open.
|
||||
pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
|
||||
pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "penpot", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
|
||||
|
||||
/// Whether `path` names a file type that opens as a document (the extension, in any case).
|
||||
pub(crate) fn opens(path: &Path) -> bool {
|
||||
path.extension().and_then(OsStr::to_str).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG:
|
||||
/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.penpot`, file JSON) rather than an image or SVG:
|
||||
/// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one.
|
||||
pub(crate) fn is_document(path: &Path) -> bool {
|
||||
path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "penpot", "json"].iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// Reads the contents of dropped file `name` that has no file on disk to read by path.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
+10
-2
@@ -49,7 +49,7 @@ page 1 "Screens": 380 nodes, 4 top-level
|
||||
…
|
||||
```
|
||||
|
||||
### `export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]`
|
||||
### `export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]`
|
||||
|
||||
- `<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.
|
||||
@@ -65,6 +65,13 @@ page 1 "Screens": 380 nodes, 4 top-level
|
||||
documents and small test files, in other software; other apps may not open every file. What was
|
||||
left out (comments, Dev Mode data, some bindings and settings…) is printed as `note:` lines;
|
||||
`docs/file-format-parity.md` lists what was checked and what is unchecked.
|
||||
- `--format penpot` (or an `<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 exports with visible descendants more than 256 levels below an exported root fail with
|
||||
@@ -96,7 +103,8 @@ By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.f
|
||||
shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.tif/.bmp`
|
||||
(an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`,
|
||||
`.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's
|
||||
frames, one page each), `.fig` (the whole file, experimental, as `export --format fig`).
|
||||
frames, one page each), `.fig` (the whole file, experimental, as `export --format fig`), `.penpot`
|
||||
(the whole file as a Penpot 2.x archive, as `export --format penpot`).
|
||||
|
||||
### `render <file> -o <out.png> [--scale N] [--page NAME]`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (M6: offline Penpot import/export with existing model and bounded parsing) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
@@ -17,6 +17,7 @@ and prototype interactions are still unchecked there.
|
||||
|
||||
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|
||||
|---|---|---|---|---|---|
|
||||
| `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](PENPOT_IMPORT.md). | further live Penpot and cross-platform QA |
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
@@ -165,6 +166,7 @@ each.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. |
|
||||
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before |
|
||||
| 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards |
|
||||
|
||||
Reference in New Issue
Block a user