diff --git a/Cargo.lock b/Cargo.lock index 0541c98ad..40a16fe7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1631,6 +1631,7 @@ dependencies = [ "thiserror 2.0.21", "wat", "web-time", + "zip 2.6.1", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 11f752f4c..9fdd9de36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (`.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -341,6 +341,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | | 2026-10-24 | minor | 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-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | | 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 | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index ba516a83e..8c7881c6b 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -34,9 +34,9 @@ fonts built into FigCraft, for renders that match on every machine. Otherwise in 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 -wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file -per top-level frame into the directory, or one file when has an image extension or --node -is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may +wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an +image layer). `export` writes one file per top-level frame into the directory, or one file when + has an image extension or --node is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may not open every file); PDFs take print options (a bleed like 3mm or 0.125in, 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. @@ -115,6 +115,9 @@ fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines[0]["ok"], json!(true), "{lines:?}"); + let fonts: Vec = serde_json::from_value(lines[1]["result"].clone()).unwrap(); + assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1ac9f0e12..ebd07a82a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -32,5 +32,6 @@ same-file = { workspace = true } web-time = "1.1.0" [dev-dependencies] +zip = { workspace = true } wat = { workspace = true } image = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 72502d16b..2379026b3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", false, open ), @@ -105,7 +105,7 @@ pub fn specs() -> Vec { spec!( "file.import", "Import", - "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", + "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", true, import ), @@ -558,6 +558,9 @@ fn load_with( if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { return Ok((r?, true)); } + if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; link_instances(&mut doc); @@ -573,6 +576,219 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. +fn load_sketch(bytes: &[u8], name: &str) -> Option> { + if !figcraft_format::sketch::is_sketch(bytes) { + return None; + } + // The import and what follows read the installed fonts: the system's must be known first (the + // desktop app scans them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); + let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); + let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + match_font_families(&mut doc); + platform_tracking(&mut doc); + sketch_line_heights(&mut doc); + link_instances(&mut doc); + reflow_overridden_text(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + +/// Instances of a stack sized apart from their main are laid out at their size, and texts in +/// instances whose string an override changed are measured again with the auto layout around them +/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout +/// Sketch saved. +fn reflow_overridden_text(doc: &mut Document) { + let changed: Vec = doc + .nodes() + .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { + (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, + _ => false, + }) + .map(|n| n.id) + .collect(); + let m = crate::measure::TextMeasure; + // Instances of a stack at another size than their main: the stack at their size, innermost first. + let mut resized: Vec<(usize, NodeId)> = doc + .nodes() + .filter(|n| n.layout.is_some()) + .filter(|n| match &n.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) + } + _ => false, + }) + .map(|n| (doc.ancestors(n.id).len(), n.id)) + .collect(); + resized.sort_by_key(|r| std::cmp::Reverse(r.0)); + for (_, id) in resized { + figcraft_layout::layout_frame(doc, id, &m); + } + for id in changed { + if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + figcraft_layout::set_size(doc, id, w, h); + } + figcraft_layout::layout_up(doc, id, &m); + } +} + +/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from +/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and +/// case. +fn match_font_families(doc: &mut Document) { + let installed = figcraft_text::fontdb::family_names(); + let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut fix = |style: &mut figcraft_doc::TextStyle| { + let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); + if let Some(f) = found { + style.font_family = f.clone(); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + fix(&mut td.style); + for r in &mut td.runs { + fix(&mut r.style); + } + } + } + // Shared text styles too, so they stay like the layers linked to them. + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + fix(style); + } + } +} + +/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine +/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so +/// lines measure and break as they did there. A named style's optical size follows the text size +/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value. +fn platform_tracking(doc: &mut Document) { + let mut cache: std::collections::BTreeMap<(String, String), Option>> = + std::collections::BTreeMap::new(); + let mut face = |style: &figcraft_doc::TextStyle| { + cache + .entry((style.font_family.clone(), style.font_style.clone())) + .or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + }) + .clone() + }; + let mut track = |style: &mut figcraft_doc::TextStyle| { + let Some(f) = face(style) else { return }; + let t = f.tracking(style.font_size); + if t != 0.0 && t.is_finite() { + let px = style.letter_spacing.resolve(style.font_size); + style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); + } + if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() { + style.variations.entry("opsz".into()).or_insert(style.font_size); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + track(&mut td.style); + for r in &mut td.runs { + track(&mut r.style); + } + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + track(style); + } + } +} + +/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their +/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. +const TALL_LINE_FAMILIES: [&str; 11] = + ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; + +/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its +/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch +/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth +/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres +/// the font's own ascent and descent in the line. `(0, 0)` without metrics. +pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { + let k = size / upem; + let (a, d) = (ascent * k, descent * k); + if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { + return (0.0, 0.0); + } + let natural = a.round() + d.round(); + let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; + let sketch_baseline = line - d.round(); + let figcraft_baseline = (line - (a + d)) / 2.0 + a; + (line, sketch_baseline - figcraft_baseline) +} + +/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, +/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a +/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text +/// styles take the line too. +fn sketch_line_heights(doc: &mut Document) { + // Per family and style: the face's ascent, descent and units per em. + type Metrics = Option<(f64, f64, f64)>; + let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); + let mut set = |style: &mut TextStyle| -> f64 { + if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { + return 0.0; + } + let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + .map(|f| (f.ascent, f.descent, f.upem)) + }); + let Some((a, d, upem)) = m else { return 0.0 }; + let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); + if line > 0.0 { + style.line_height = LineHeight::Percent(line / style.font_size * 100.0); + } + down + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + // An auto layout places its texts itself (its next pass would take a move back). + let laid_out = + doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let Some(n) = doc.node_mut(id) else { continue }; + let NodeKind::Text(td) = &mut n.kind else { continue }; + let down = set(&mut td.style); + for r in &mut td.runs { + set(&mut r.style); + } + // Down in the layer's own frame (a rotated text moves along its own axis). + if !laid_out && down.is_finite() && down != 0.0 { + n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + set(style); + } + } +} + +/// The installed family `family` means when it isn't installed under that exact name: the one +/// whose name is the same without spaces, ignoring case. +pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { + let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); + if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { + return None; + } + let want = key(family); + installed.iter().find(|f| key(f) == want).cloned() +} + /// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::fig::try_import(bytes, name)?; @@ -1412,7 +1628,7 @@ fn import(s: &mut Session, p: &Value) -> Result { } Ok(out) } - "figcraft" | "json" | "fig" => { + "figcraft" | "json" | "fig" | "sketch" => { let resolver = path.as_deref().map(rest_images); let images: figcraft_format::rest::ImageResolver = match &resolver { Some(r) => r, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d7d6a2cb3..3fc61bfe1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1886,6 +1886,8 @@ mod shapes_behaviour_tests; #[cfg(test)] mod shortcut_parity_tests; #[cfg(test)] +mod sketch_import_tests; +#[cfg(test)] mod slot_tests; #[cfg(test)] mod stroke_style_tests; diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs new file mode 100644 index 000000000..f9d3f14bb --- /dev/null +++ b/crates/engine/src/sketch_import_tests.rs @@ -0,0 +1,529 @@ +//! `.sketch` files through the file commands. The importer's own tests (fixtures built in code) +//! are in `figcraft_format::sketch`. + +use std::io::Write; + +use base64::Engine; +use serde_json::{Value, json}; + +use figcraft_doc::NodeKind; + +use crate::Session; + +/// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. +fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { + sketch_with(app_version, pages, &[]) +} + +/// [`sketch`] with `layers` on the first page. +fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec { + sketch_with_doc(app_version, pages, layers, json!({})) +} + +/// [`sketch_with`] with the fields of `extra` added to `document.json` (shared styles…). +fn sketch_with_doc(app_version: &str, pages: &[(&str, &str)], layers: &[Value], extra: Value) -> Vec { + let refs: Vec = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect(); + let mut document = json!({"_class": "document", "pages": refs}); + for (k, v) in extra.as_object().into_iter().flatten() { + document[k] = v.clone(); + } + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let o = zip::write::SimpleFileOptions::default(); + z.start_file("document.json", o).unwrap(); + z.write_all(document.to_string().as_bytes()).unwrap(); + z.start_file("meta.json", o).unwrap(); + // The format version that Sketch writes (103 for 50; 110 and up from 52). + let version = if app_version.split('.').next().and_then(|v| v.parse::().ok()).is_some_and(|v| v >= 52) { 125 } else { 103 }; + z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": version}).to_string().as_bytes()).unwrap(); + for (i, (id, name)) in pages.iter().enumerate() { + let ls = if i == 0 { layers.to_vec() } else { Vec::new() }; + z.start_file(format!("pages/{id}.json"), o).unwrap(); + z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": ls}).to_string().as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + out +} + +fn temp(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("figcraft-sketch-test-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + (dir, path) +} + +fn page_names(s: &Session) -> Vec { + let doc = s.doc().unwrap(); + doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect() +} + +#[test] +fn opened_sketch_files_have_their_pages_and_no_path() { + let (dir, path) = temp("design.sketch", &sketch("50.2", &[("A", "Screens"), ("B", "Symbols")])); + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(r.get("summary").is_some()); + assert_eq!(page_names(&s), ["Screens", "Symbols"]); + // Never saved over: the document has no path, so Save asks for one. + assert!(s.docs[s.active].path.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sketch_import_appends_pages() { + let (dir, path) = temp("more.sketch", &sketch("83.2", &[("A", "Imported")])); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = page_names(&s).len(); + s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap(); + let names = page_names(&s); + assert_eq!(names.len(), before + 1); + assert_eq!(names.last().unwrap(), "Imported"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sketch_data_opens_with_a_summary() { + let data = base64::engine::general_purpose::STANDARD.encode(sketch("50.2", &[("A", "Page")])); + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + assert!(r.get("summary").is_some()); + assert_eq!(page_names(&s), ["Page"]); +} + +#[test] +fn old_and_damaged_sketch_files_fail_cleanly() { + let mut s = Session::new(); + let (dir, path) = temp("old.sketch", &sketch("47.1", &[("A", "P")])); + let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("older than 48"), "{e}"); + let mut broken = sketch("50.2", &[("A", "P")]); + broken.truncate(broken.len() / 2); + std::fs::write(&path, &broken).unwrap(); + assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +fn shape(class: &str, name: &str, frame: [f64; 4], extra: Value) -> Value { + let mut l = json!({"_class": class, "do_objectID": name, "name": name, "isVisible": true, "resizingConstraint": 63, + "frame": {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]}, "style": {"fills": []}}); + for (k, v) in extra.as_object().unwrap() { + l[k] = v.clone(); + } + l +} + +fn fill(grey: f64) -> Value { + json!({"style": {"fills": [{"isEnabled": true, "fillType": 0, "color": {"red": grey, "green": grey, "blue": grey, "alpha": 1}}]}}) +} + +/// Sketch draws a mask's own fill under what it clips; the clipped layers show inside it. +#[test] +fn sketch_masks_show_their_content() { + let oval = shape("oval", "Ring", [0.0, 0.0, 60.0, 60.0], json!({"booleanOperation": -1, "points": []})); + let mut mask = shape("shapeGroup", "Circle", [0.0, 0.0, 60.0, 60.0], fill(0.8)); + mask["hasClippingMask"] = json!(true); + mask["layers"] = json!([oval]); + let pts = ["{0, 0}", "{1, 0}", "{1, 1}", "{0, 1}"].map(|p| json!({"point": p, "curveFrom": p, "curveTo": p, "curveMode": 1})); + let body = shape("shapePath", "Square", [0.0, 0.0, 20.0, 20.0], json!({"points": pts, "isClosed": true, "booleanOperation": -1})); + let mut content = shape("shapeGroup", "Body", [20.0, 20.0, 20.0, 20.0], fill(0.2)); + content["layers"] = json!([body]); + let icon = shape("group", "Icon", [0.0, 0.0, 60.0, 60.0], json!({"layers": [mask, content]})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[icon])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let icon = doc.nodes().find(|n| n.name == "Icon").unwrap().id; + let pix = figcraft_render::render_node(doc, icon, 1.0).unwrap(); + let px = |x: u32, y: u32| pix.pixel(x, y).unwrap(); + // The square in the middle, dark grey. + let c = px(30, 30); + assert!(c.alpha() > 200 && c.red() < 100, "content: {c:?}"); + // The mask's own fill around it, light grey; outside the circle nothing. + let m = px(30, 8); + assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}"); + assert!(px(2, 2).alpha() < 50); +} + +/// Family names guessed from PostScript names find the installed family whatever its spaces. +#[test] +fn sketch_font_families_match_installed_ones() { + let installed = ["PingFang SC".to_string(), "Helvetica Neue".to_string(), "IBM Plex Sans".to_string()]; + let m = |f: &str| crate::cmd::file::installed_family(f, &installed); + assert_eq!(m("Ping Fang SC").as_deref(), Some("PingFang SC")); + assert_eq!(m("Helvetica Neue"), None, "already right"); + assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans")); + assert_eq!(m("Comic Sans"), None); +} + +/// Editing a master changes its instances, and a resized instance's layout isn't an override. +#[test] +fn sketch_instances_stay_live() { + let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []})); + let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]})); + let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let find = |s: &Session, inst: bool| { + let doc = s.doc().unwrap(); + let root = doc + .nodes() + .find(|n| if inst { matches!(n.kind, NodeKind::Instance { .. }) } else { matches!(n.kind, NodeKind::Component { .. }) }) + .unwrap() + .id; + doc.subtree(root).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Tag")).unwrap() + }; + let (main_tag, inst_tag) = (find(&s, false), find(&s, true)); + assert!(s.doc().unwrap().node(inst_tag).unwrap().overrides.is_empty(), "{:?}", s.doc().unwrap().node(inst_tag).unwrap().overrides); + s.execute("node.set", &json!({"id": main_tag, "props": {"fill": "#ff0000"}})).unwrap(); + let doc = s.doc().unwrap(); + let fill = &doc.node(inst_tag).unwrap().fills; + assert!(fill.iter().any(|p| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r > 0.99 && color.g < 0.01)), "{fill:?}"); + let b = doc.world_bounds(inst_tag).unwrap(); + assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}"); +} + +/// Local only (`FIGCRAFT_SKETCH_SAMPLES` names a folder of `.sketch` files): each opens (Sketch 48 +/// and newer) with its instances linked and finite geometry, or is refused as too old. +#[test] +fn local_sketch_samples_open() { + let Ok(dir) = std::env::var("FIGCRAFT_SKETCH_SAMPLES") else { return }; + let mut files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect(); + files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch"))); + files.sort(); + assert!(!files.is_empty(), "no .sketch files in {dir}"); + for path in files { + let started = std::time::Instant::now(); + let mut s = Session::new(); + match s.execute("file.open", &json!({"path": path.to_string_lossy()})) { + Err(e) => { + let e = e.to_string(); + assert!(e.contains("older than 48"), "{}: {e}", path.display()); + eprintln!("{}: refused as too old", path.display()); + } + Ok(r) => { + let doc = s.doc().unwrap(); + let (mut layers, mut instances, mut unlinked) = (0, 0, 0); + for n in doc.nodes() { + layers += 1; + assert!( + n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()), + "{}: {}", + path.display(), + n.name + ); + if let NodeKind::Instance { component, .. } = n.kind { + instances += 1; + if !doc.contains(component) { + unlinked += 1; + } + } + } + assert_eq!(unlinked, 0, "{}", path.display()); + let notes = r.get("warnings").and_then(|w| w.as_array()).map_or(0, Vec::len); + eprintln!("{}: {layers} layers, {instances} instances, {notes} notes, {:?}", path.display(), started.elapsed()); + } + } + } +} + +/// Local only (`FIGCRAFT_SKETCH_SAMPLES` and `FIGCRAFT_SKETCHTOOL`, the path of Sketch's +/// `sketchtool`): every artboard of each sample (Sketch 48 and newer) drawn by FigCraft is compared +/// with Sketch's own export of it. Prints the share of pixels that differ per artboard (a channel +/// off by more than 40, on white); fails past 10% (a broken artboard, not antialiasing or fonts). +#[test] +fn local_sketch_samples_match_sketch() { + let (Ok(dir), Ok(tool)) = (std::env::var("FIGCRAFT_SKETCH_SAMPLES"), std::env::var("FIGCRAFT_SKETCHTOOL")) else { return }; + // Text draws in the fonts Sketch uses only once the system fonts are known. + figcraft_text::fontdb::scan_system_fonts(); + let mut files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect(); + files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch"))); + files.sort(); + let mut worst: Vec<(f64, String)> = Vec::new(); + for path in files { + let mut s = Session::new(); + if s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err() { + continue; + } + let out = std::env::temp_dir().join(format!("figcraft-sketch-ref-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let status = std::process::Command::new(&tool) + .args(["export", "artboards"]) + .arg(&path) + .arg(format!("--output={}", out.display())) + .args(["--formats=png", "--scales=1", "--use-id-for-name=YES", "--include-symbols=NO"]) + .output() + .unwrap(); + assert!(status.status.success(), "sketchtool failed on {}", path.display()); + // Artboards by page and order, from the file itself. + let archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap(); + let mut archive = archive; + let mut read = |name: &str| -> Value { + let mut s = String::new(); + std::io::Read::read_to_string(&mut archive.by_name(name).unwrap(), &mut s).unwrap(); + serde_json::from_str(&s).unwrap() + }; + let document = read("document.json"); + let doc = s.doc().unwrap(); + let refs: Vec = document["pages"].as_array().unwrap().iter().map(|r| r["_ref"].as_str().unwrap().to_string()).collect(); + for (page_ref, page_id) in refs.iter().zip(doc.pages.iter()) { + let page = read(&format!("{page_ref}.json")); + let boards: Vec = page["layers"] + .as_array() + .unwrap() + .iter() + .filter(|l| l["_class"] == "artboard") + .map(|l| l["do_objectID"].as_str().unwrap().to_string()) + .collect(); + let frames: Vec = + doc.node(*page_id).unwrap().children.iter().copied().filter(|c| matches!(doc.node(*c).unwrap().kind, NodeKind::Frame)).collect(); + assert_eq!(boards.len(), frames.len(), "{}: artboards of {page_ref}", path.display()); + for (id, frame) in boards.iter().zip(frames) { + let Ok(reference) = image::open(out.join(format!("{id}.png"))) else { continue }; + let reference = reference.to_rgba8(); + let ours = figcraft_render::render_node(doc, frame, 1.0).unwrap(); + let (w, h) = (reference.width().min(ours.width()), reference.height().min(ours.height())); + let ours_rgba = figcraft_render::to_rgba8(&ours); + // Both on white, straight alpha. + let on_white = |c: &[u8], i: usize| { + let a = f64::from(c[3]) / 255.0; + f64::from(c[i]) * a + 255.0 * (1.0 - a) + }; + let mut bad = 0u64; + for y in 0..h { + for x in 0..w { + let r = reference.get_pixel(x, y).0; + let at = ((y * ours.width() + x) * 4) as usize; + let Some(o) = ours_rgba.get(at..at + 4) else { continue }; + if (0..3).any(|i| (on_white(&r, i) - on_white(o, i)).abs() > 40.0) { + bad += 1; + } + } + } + let share = bad as f64 * 100.0 / (f64::from(w) * f64::from(h)).max(1.0); + let name = doc.node(frame).unwrap().name.clone(); + eprintln!("{share:6.2}% {} {name}", path.file_name().unwrap().to_string_lossy()); + worst.push((share, format!("{}: {name}", path.display()))); + } + } + let _ = std::fs::remove_dir_all(&out); + } + worst.sort_by(|a, b| b.0.total_cmp(&a.0)); + assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}"); +} + +/// Sketch draws Apple's fonts with the tracking their `trak` table gives at each size (the +/// platform does it on its own); the imported text carries it as letter spacing (macOS only). +#[test] +fn sketch_text_keeps_the_platform_tracking() { + if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "SFUIText-Regular", "size": 13}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let t = shape("text", "Label", [0.0, 0.0, 100.0, 20.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + let ls = td.style.letter_spacing.resolve(13.0); + assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}"); + // A named style's optical size follows the size there: the text carries it. + let f = figcraft_text::fontdb::find_face(&td.style.font_family, &td.style.font_style).unwrap(); + let named = f.axis("opsz").is_some() && f.instance(&td.style.font_style).is_some(); + assert_eq!(td.style.variations.get("opsz").copied(), named.then_some(13.0), "{:?}", td.style); +} + +/// A text an override changed is measured again, and the stack around it re-flows: a hugging chip +/// grows to its new label. +#[test] +fn sketch_overridden_text_reflows_its_stack() { + let font = json!({"name": "Helvetica", "size": 12}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let label = shape( + "text", + "Label", + [20.0, 4.0, 14.0, 16.0], + json!({"attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let dot = shape("oval", "Dot", [8.0, 9.0, 6.0, 6.0], json!({"points": []})); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 0, "alignItems": 1, "allGuttersGap": 6, "stackingOrder": 0}); + let master = shape( + "symbolMaster", + "Chip", + [0.0, 0.0, 44.0, 24.0], + json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1, + "leftPadding": 8, "rightPadding": 10, "topPadding": 4, "bottomPadding": 4, "layers": [label, dot]}), + ); + let ov = json!([{"_class": "overrideValue", "overrideName": "Label_stringValue", "value": "A much longer label here"}]); + let inst = shape( + "symbolInstance", + "Long", + [0.0, 100.0, 160.0, 24.0], + json!({"symbolID": "S", "overrideValues": ov, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let inst = doc.nodes().find(|n| n.name == "Long").unwrap().id; + let label = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Label")).unwrap(); + let dot = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Dot")).unwrap(); + let (i, l, d) = (doc.world_bounds(inst).unwrap(), doc.world_bounds(label).unwrap(), doc.world_bounds(dot).unwrap()); + assert!(l.x1 - l.x0 > 60.0, "the label was measured: {l:?}"); + assert!((d.x0 - i.x0 - 8.0).abs() < 0.5, "the dot after the padding: {d:?} in {i:?}"); + assert!((l.x0 - d.x1 - 6.0).abs() < 0.5, "the label after the gap: {l:?}"); + assert!((i.x1 - l.x1 - 10.0).abs() < 0.5, "the chip hugs: {i:?} {l:?}"); +} + +/// An instance sized apart from its master lays its master's stack out at its own size: a wide +/// button keeps its label and icon together in the middle. +#[test] +fn sketch_resized_stack_instances_reflow() { + let a = shape("rectangle", "A", [22.0, 12.0, 40.0, 20.0], json!({"points": []})); + let b = shape("rectangle", "B", [70.0, 12.0, 20.0, 20.0], json!({"points": []})); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 1, "alignItems": 1, "allGuttersGap": 8, "stackingOrder": 0}); + let master = shape( + "symbolMaster", + "Button", + [0.0, 0.0, 112.0, 44.0], + json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1, + "leftPadding": 22, "rightPadding": 22, "topPadding": 12, "bottomPadding": 12, "layers": [b, a]}), + ); + let inst = shape( + "symbolInstance", + "Wide", + [0.0, 100.0, 342.0, 44.0], + json!({"symbolID": "S", "overrideValues": [], "horizontalSizing": 0, "verticalSizing": 1}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let inst = doc.nodes().find(|n| n.name == "Wide").unwrap().id; + let at = |name: &str| doc.world_bounds(doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == name)).unwrap()).unwrap(); + let (a, b) = (at("A"), at("B")); + // 40 + 8 + 20 = 68 wide, centred in 342: from 137. + assert!((a.x0 - 137.0).abs() < 0.5, "{a:?}"); + assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}"); + assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6); +} + +/// Sketch's natural line: the font's ascent and descent each rounded to whole pixels, without its +/// line gap, the baseline at the rounded ascent; the classic Mac fonts get a fifth more, all of it +/// above the text. +#[test] +fn sketch_natural_line_rule() { + use crate::cmd::file::sketch_line; + // Helvetica Neue Medium (975 + 217 of 1000, a gap of 29 left out) at 17 px: 16.575 → 17 and + // 3.689 → 4, a line of 21 with the baseline at 17. FigCraft puts the baseline at + // (21 − 20.264) / 2 + 16.575 = 16.943, so the text moves down 0.057. + let (line, down) = sketch_line(975.0, 217.0, 1000.0, "Helvetica Neue", 17.0); + assert!((line - 21.0).abs() < 1e-9 && (down - 0.057).abs() < 1e-9, "{line} {down}"); + // Helvetica at 100 px: 77 + 23 and a fifth more, 120, all of it above: the baseline at 97 + // where FigCraft centres it at 87. + let (line, down) = sketch_line(1577.0, 471.0, 2048.0, "helvetica", 100.0); + assert!((line - 120.0).abs() < 1e-9 && (down - 10.0).abs() < 0.05, "{line} {down}"); + assert_eq!(sketch_line(0.0, 0.0, 0.0, "X", 12.0), (0.0, 0.0), "no metrics, no change"); + assert_eq!(sketch_line(900.0, 200.0, 1000.0, "X", f64::NAN), (0.0, 0.0)); +} + +/// Text without a fixed line height gets Sketch's natural line as a share of its size, in the +/// layer and in shared text styles. +#[test] +fn sketch_text_gets_sketchs_natural_line() { + if !std::path::Path::new("/System/Library/Fonts/HelveticaNeue.ttc").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "HelveticaNeue", "size": 100}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let t = shape("text", "Label", [0.0, 0.0, 200.0, 120.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + // 95.2 → 95 and 21.3 → 21: 116 px, as a share of the size. + let figcraft_doc::LineHeight::Percent(p) = td.style.line_height else { panic!("{:?}", td.style.line_height) }; + assert!((p - 116.0).abs() < 1e-9, "{p}"); +} + +/// A text an auto layout places isn't moved to Sketch's baseline (the next layout pass would take +/// the move back): laying the stack out again leaves it where the import put it. +#[test] +fn sketch_text_in_a_stack_stays_put() { + if !std::path::Path::new("/System/Library/Fonts/Helvetica.ttc").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "Helvetica", "size": 40}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let label = shape( + "text", + "Label", + [10.0, 10.0, 50.0, 48.0], + json!({"attributedString": {"string": "Hi", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 1, "justifyContent": 0, "alignItems": 0, "allGuttersGap": 0, "stackingOrder": 0}); + let stack = shape( + "group", + "Stack", + [0.0, 0.0, 200.0, 68.0], + json!({"groupBehavior": 1, "groupLayout": layout, "leftPadding": 10, "topPadding": 10, "rightPadding": 10, "bottomPadding": 10, "layers": [label]}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[stack])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + let stack = doc.nodes().find(|n| n.name == "Stack").unwrap().id; + let label = doc.nodes().find(|n| n.name == "Label").unwrap().id; + let before = doc.world_bounds(label).unwrap(); + figcraft_layout::layout_frame(&mut doc, stack, &crate::measure::TextMeasure); + let after = doc.world_bounds(label).unwrap(); + assert!((before.y0 - after.y0).abs() < 1e-6, "{before:?} → {after:?}"); +} + +/// Shared text styles get what their linked layers get on import: the installed family's name and +/// the platform's tracking (a layer and its style stay alike). +#[test] +fn sketch_shared_text_styles_match_their_layers() { + if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let attrs = json!({"MSAttributedStringFontAttribute": {"attributes": {"name": "SFUIText-Regular", "size": 13}}}); + let style = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": attrs.clone()}}); + let shared = json!({"_class": "sharedStyle", "do_objectID": "TS", "name": "Body", "value": style.clone()}); + let run = json!({"location": 0, "length": 2, "attributes": attrs}); + let mut linked = style; + linked["sharedObjectID"] = json!("TS"); + let t = shape( + "text", + "Label", + [0.0, 0.0, 100.0, 20.0], + json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0, "style": linked}), + ); + let extra = json!({"layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": [shared]}}); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with_doc("64", &[("A", "Page")], &[t], extra)); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + let st = doc + .styles + .iter() + .find_map(|st| match &st.kind { + figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(st.font_family, td.style.font_family); + assert_eq!(st.letter_spacing.resolve(13.0), td.style.letter_spacing.resolve(13.0), "{st:?}"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 78443fcd5..ac05185f5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -434,6 +434,9 @@ fn every_command_survives_junk_params() { // Online files: ids, servers, refreshes, actions and names as junk. json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}), json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}), + // Files as base64 data: a damaged `.sketch` (zip magic, then junk) and an old one (SQLite). + json!({"data": "UEsDBBQAAAAIAP////8=", "name": "x.sketch"}), + json!({"data": "U1FMaXRlIGZvcm1hdCAzAA==", "name": "x.sketch", "path": null}), // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), @@ -658,3 +661,16 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +/// Damaged and too-old `.sketch` data sent to the file commands (which the junk sweep skips) fail +/// with an ordinary error, not a caught panic. +#[test] +fn damaged_sketch_data_fails_without_panicking() { + for data in ["UEsDBBQAAAAIAP////8=", "U1FMaXRlIGZvcm1hdCAzAA=="] { + for cmd in ["file.open", "file.import"] { + let mut s = sess(); + let r = s.execute(cmd, &json!({"data": data, "name": "x.sketch"})); + assert!(matches!(r, Err(ref e) if !matches!(e, EngineError::Internal { .. })), "{cmd}: {r:?}"); + } + } +} diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index c04043ae0..d81a5856e 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -126,7 +126,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma /// A font style under the name the format uses: the standard weight names as words ("SemiBold" /// → "Semi Bold", "ExtraBoldItalic" → "Extra Bold Italic", "Regular Italic" → "Italic"). Other /// names stay as they are. -fn style_name(style: &str) -> String { +pub(crate) fn style_name(style: &str) -> String { const WEIGHTS: [&str; 12] = ["Thin", "Extra Light", "Ultra Light", "Light", "Regular", "Medium", "Semi Bold", "Demi Bold", "Bold", "Extra Bold", "Ultra Bold", "Black"]; let compact = |s: &str| s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect::(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 33101b624..4136a42f4 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -20,6 +20,7 @@ pub mod fig; pub mod library; pub mod rest; +pub mod sketch; pub mod thumbnail; pub use library::{is_valid_library_id, read_library, read_library_within}; diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index ba2e3e99b..15fbce9d1 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -187,6 +187,17 @@ pub(crate) fn import_with_networks( import_parts(v, None, images, networks, max_nodes, true) } +/// [`import_with_networks`] for imports other than `.fig` (no `.fig` source identities): the +/// `.sketch` import. +pub(crate) fn import_with_networks_plain( + v: &Value, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { + import_parts(v, None, images, networks, max_nodes, false) +} + fn import_parts( v: &Value, variables: Option<&Value>, diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 367677a6f..99ceb5002 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -102,6 +102,15 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { let weight = num(s, "fontWeight").map_or_else(|| t.weight(), |w| w.clamp(1.0, 1000.0) as u16); t.font_style = style_name(weight, flag(s, "italic").unwrap_or_else(|| t.italic())); } + // Variable font axes, `{tag: value}` (FigCraft's own key; the Sketch importer writes it). + if let Some(axes) = s.get("fontVariations").and_then(Value::as_object) { + t.variations = axes + .iter() + .take(64) + .filter(|(k, _)| k.len() == 4 && k.is_ascii()) + .filter_map(|(k, v)| Some((k.clone(), v.as_f64().filter(|x| x.is_finite())?.clamp(-1e5, 1e5)))) + .collect(); + } if let Some(size) = num(s, "fontSize").filter(|x| *x > 0.0) { t.font_size = size.clamp(0.5, 10_000.0); } diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs new file mode 100644 index 000000000..19d23eca4 --- /dev/null +++ b/crates/format/src/sketch/fixture.rs @@ -0,0 +1,206 @@ +//! Synthetic `.sketch` files for the tests: the JSON of a document, its pages and `meta.json`, +//! zipped the way Sketch writes them. Layers are built with small helpers and adjusted with +//! [`with`]. + +use std::io::Write; + +use serde_json::{Value, json}; + +/// A `.sketch` zip: `document.json`, `meta.json` saying `app_version` / `version`, one +/// `pages/.json` per page and any other `files` (images, fonts). +pub(crate) fn sketch_zip(app_version: &str, version: u64, document: Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let meta = json!({ + "app": "com.bohemiancoding.sketch3", + "appVersion": app_version, + "version": version, + "compatibilityVersion": 99, + "pagesAndArtboards": {}, + }); + raw_zip(&document, &meta, pages, files) +} + +/// A `.sketch` zip with the given `meta.json` as is. +pub(crate) fn raw_zip(document: &Value, meta: &Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("document.json", opts).unwrap(); + z.write_all(document.to_string().as_bytes()).unwrap(); + z.start_file("meta.json", opts).unwrap(); + z.write_all(meta.to_string().as_bytes()).unwrap(); + for p in pages { + let id = p["do_objectID"].as_str().unwrap_or("page"); + z.start_file(format!("pages/{id}.json"), opts).unwrap(); + z.write_all(p.to_string().as_bytes()).unwrap(); + } + for (name, bytes) in files { + z.start_file(*name, opts).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap(); + } + out +} + +/// `document.json` listing `pages` (ids) in order, with empty shared-style containers. +pub(crate) fn document(pages: &[&str]) -> Value { + json!({ + "_class": "document", + "do_objectID": "DOC", + "colorSpace": 1, + "currentPageIndex": 0, + "foreignLayerStyles": [], + "foreignSymbols": [], + "foreignTextStyles": [], + "layerStyles": {"_class": "sharedStyleContainer", "objects": []}, + "layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": []}, + "layerSymbols": {"_class": "symbolContainer", "objects": []}, + "pages": pages.iter().map(|p| json!({ + "_class": "MSJSONFileReference", + "_ref_class": "MSImmutablePage", + "_ref": format!("pages/{p}"), + })).collect::>(), + }) +} + +fn frame(x: f64, y: f64, w: f64, h: f64) -> Value { + json!({"_class": "rect", "constrainProportions": false, "x": x, "y": y, "width": w, "height": h}) +} + +fn layer(class: &str, id: &str, name: &str, x: f64, y: f64, w: f64, h: f64) -> Value { + json!({ + "_class": class, + "do_objectID": id, + "name": name, + "frame": frame(x, y, w, h), + "isVisible": true, + "isLocked": false, + "rotation": 0, + "isFlippedHorizontal": false, + "isFlippedVertical": false, + "resizingConstraint": 63, + "style": {"_class": "style", "fills": [], "borders": [], "shadows": [], "innerShadows": []}, + }) +} + +pub(crate) fn page(id: &str, name: &str, layers: Vec) -> Value { + with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers)) +} + +pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers)) +} + +pub(crate) fn group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("group", id, id, x, y, w, h), "layers", Value::Array(layers)) +} + +/// A rectangle with its four generated corner points. +pub(crate) fn rect(id: &str, x: f64, y: f64, w: f64, h: f64) -> Value { + let pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].map(|(px, py)| point(px, py)); + let mut l = with(layer("rectangle", id, id, x, y, w, h), "points", Value::Array(pts.to_vec())); + l["isClosed"] = json!(true); + l["fixedRadius"] = json!(0); + l +} + +/// A straight curve point at `(x, y)` in 0–1 of the layer's frame. +pub(crate) fn point(x: f64, y: f64) -> Value { + let p = format!("{{{x}, {y}}}"); + json!({"_class": "curvePoint", "cornerRadius": 0, "curveFrom": p, "curveTo": p, "curveMode": 1, "hasCurveFrom": false, "hasCurveTo": false, "point": p}) +} + +/// `l` with `key` set to `value`. +pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value { + if let Some(m) = l.as_object_mut() { + m.insert(key.to_string(), value); + } + l +} + +/// A `shapePath` through `points` (`(x, y)` in 0–1 of its frame), straight segments. +pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f64, f64)], closed: bool) -> Value { + let pts: Vec = points.iter().map(|(px, py)| point(*px, *py)).collect(); + let mut l = with(layer("shapePath", id, id, x, y, w, h), "points", Value::Array(pts)); + l["isClosed"] = json!(closed); + l["booleanOperation"] = json!(-1); + l +} + +/// A `shapeGroup` (Sketch 51 and older wrap every shape in one) holding `layers`. +pub(crate) fn shape_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers)) +} + +/// A Sketch colour. +pub(crate) fn color(r: f64, g: f64, b: f64, a: f64) -> Value { + json!({"_class": "color", "red": r, "green": g, "blue": b, "alpha": a}) +} + +/// An enabled solid fill. +pub(crate) fn solid_fill(r: f64, g: f64, b: f64, a: f64) -> Value { + json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color(r, g, b, a), + "contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}}) +} + +/// `l` with `style.` set to `value`. +pub(crate) fn styled(mut l: Value, key: &str, value: Value) -> Value { + l["style"][key] = value; + l +} + +/// A text layer showing `string`, one run per `(location, length, font, size, rgba)`; locations and +/// lengths count UTF-16 code units, as Sketch does. +pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: &[(usize, usize, &str, f64, [f64; 4])]) -> Value { + let attrs: Vec = runs + .iter() + .map(|(loc, len, font, size, [r, g, b, a])| { + json!({"_class": "stringAttribute", "location": loc, "length": len, "attributes": { + "MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": font, "size": size}}, + "MSAttributedStringColorAttribute": color(*r, *g, *b, *a), + "paragraphStyle": {"_class": "paragraphStyle", "alignment": 0}, + }}) + }) + .collect(); + let mut l = layer("text", id, id, x, y, w, h); + l["attributedString"] = json!({"_class": "attributedString", "string": string, "attributes": attrs}); + l["textBehaviour"] = json!(1); + l +} + +/// A symbol master `symbol_id` holding `layers`. +pub(crate) fn master(id: &str, symbol_id: &str, name: &str, w: f64, h: f64, layers: Vec) -> Value { + let mut l = artboard(id, name, 0.0, 0.0, w, h, layers); + l["_class"] = json!("symbolMaster"); + l["symbolID"] = json!(symbol_id); + l +} + +/// An instance of symbol `symbol_id` with `overrides` (`overrideName`, value). +pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64, overrides: &[(&str, Value)]) -> Value { + let mut l = layer("symbolInstance", id, id, x, y, w, h); + l["symbolID"] = json!(symbol_id); + l["scale"] = json!(1); + l["overrideValues"] = + Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect()); + l +} + +/// A Sketch 2025+ frame (a group that behaves as a frame) holding `layers`. +pub(crate) fn frame_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + let mut l = group(id, x, y, w, h, layers); + l["groupBehavior"] = json!(1); + l["clippingBehavior"] = json!(0); + l +} + +/// `l` as a stack (Sketch 2025+): `direction` 0 row, 1 column. +pub(crate) fn stack(mut l: Value, direction: i64, justify: i64, align: i64, gap: f64, padding: [f64; 4]) -> Value { + l["groupLayout"] = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": direction, "justifyContent": justify, "alignItems": align, + "allGuttersGap": gap, "crossAxisGutterGap": 0, "wrappingEnabled": false, "alignContent": 0, "stackingOrder": 0, "bordersAffectLayout": false}); + for (k, v) in ["leftPadding", "topPadding", "rightPadding", "bottomPadding"].iter().zip(padding) { + l[*k] = json!(v); + } + l +} diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs new file mode 100644 index 000000000..5a5cefca5 --- /dev/null +++ b/crates/format/src/sketch/frames_tests.rs @@ -0,0 +1,443 @@ +//! Sketch 2025 and newer: frames and graphics (groups with a `groupBehavior`), stacks, corners in +//! the style, pins and sizing, border sides. + +use figcraft_doc::{Document, LayoutMode, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("2026.3.1", 196, document(&["P"]), &[page("P", "Page", layers)], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +#[test] +fn frames_graphics_and_clipping() { + let mut clip_none = frame_group("Loose", 0.0, 0.0, 50.0, 50.0, vec![]); + clip_none["clippingBehavior"] = json!(2); + let mut graphic = frame_group("Icon", 100.0, 0.0, 24.0, 24.0, vec![]); + graphic["groupBehavior"] = json!(2); + let mut plain = group("Plain", 200.0, 0.0, 10.0, 10.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]); + plain["groupBehavior"] = json!(0); + let mut filled = frame_group("Filled", 300.0, 0.0, 50.0, 50.0, vec![]); + filled["style"]["fills"] = json!([solid_fill(1.0, 0.0, 0.0, 1.0)]); + let (doc, _) = open(vec![frame_group("Screen", 0.0, 100.0, 390.0, 844.0, vec![]), clip_none, graphic, plain, filled]); + assert!(matches!(node(&doc, "Screen").kind, NodeKind::Frame) && node(&doc, "Screen").clip); + assert!(!node(&doc, "Loose").clip); + assert!(matches!(node(&doc, "Icon").kind, NodeKind::Frame) && node(&doc, "Icon").clip); + assert!(matches!(node(&doc, "Plain").kind, NodeKind::Group)); + assert_eq!(node(&doc, "Filled").fills.len(), 1); + // Frames show what their style says: no white background of their own. + assert!(node(&doc, "Screen").fills.is_empty()); +} + +#[test] +fn corners_from_the_style() { + let mut a = rect("Each", 0.0, 0.0, 40.0, 40.0); + a["style"]["corners"] = + json!({"_class": "MSImmutableStyleCorners", "radii": [1, 2, 3, 4], "style": 0, "smoothing": 0.6, "prefersConcentric": false}); + let mut b = rect("Smooth", 50.0, 0.0, 40.0, 40.0); + b["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 1, "smoothing": 0.6, "prefersConcentric": false}); + let mut f = frame_group("Card", 100.0, 0.0, 100.0, 100.0, vec![]); + f["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [16, 16, 16, 16], "style": 0, "smoothing": 0.6}); + let (doc, _) = open(vec![a, b, f]); + assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(node(&doc, "Smooth").corners.radii, [12.0; 4]); + assert!((node(&doc, "Smooth").corners.smoothing - 0.6).abs() < 1e-9); + assert_eq!(node(&doc, "Each").corners.smoothing, 0.0); + assert_eq!(node(&doc, "Card").corners.radii, [16.0; 4]); +} + +#[test] +fn stacks_become_auto_layout() { + // A row: Sketch lists the first item last (on top); justify evenly, centred, gap 8. + let a = rect("First", 0.0, 0.0, 20.0, 20.0); + let b = with(rect("Second", 0.0, 0.0, 30.0, 10.0), "horizontalSizing", json!(2)); + let mut free = rect("Free", 0.0, 0.0, 5.0, 5.0); + free["flexItem"] = json!({"_class": "MSImmutableFlexItem", "ignoreLayout": true, "alignSelf": 5, "preserveSpaceWhenHidden": false}); + let mut row = stack(frame_group("Row", 0.0, 0.0, 200.0, 40.0, vec![free, b, a]), 0, 5, 1, 8.0, [4.0, 6.0, 10.0, 2.0]); + row["horizontalSizing"] = json!(0); + row["verticalSizing"] = json!(1); + let col = stack(frame_group("Column", 0.0, 100.0, 100.0, 100.0, vec![rect("One", 0.0, 0.0, 10.0, 10.0)]), 1, 3, 3, 0.0, [0.0; 4]); + let (doc, _) = open(vec![row, col]); + let r = node(&doc, "Row"); + let l = r.layout.as_ref().expect("auto layout"); + assert_eq!(l.mode, LayoutMode::Horizontal); + assert_eq!(l.gap, 8.0); + assert_eq!(l.padding, [6.0, 10.0, 2.0, 4.0], "top, right, bottom, left"); + assert!(l.reverse_z, "the first item is on top, as in Sketch"); + let text = format!("{l:?}"); + assert!(text.contains("SpaceEvenly") || text.contains("Evenly"), "{text}"); + let order: Vec<&str> = r.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(order, ["First", "Second", "Free"], "flow order is the reverse of Sketch's list"); + assert!(node(&doc, "Free").child_layout.absolute); + assert_eq!(node(&doc, "Second").child_layout.sizing_h, figcraft_doc::Sizing::Fill); + assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug); + let c = node(&doc, "Column").layout.as_ref().unwrap(); + assert_eq!(c.mode, LayoutMode::Vertical); + assert!(format!("{c:?}").contains("SpaceBetween"), "{c:?}"); +} + +/// A frame resized as an instance's layer follows its pins and sizing (Sketch 2025+ keep them in +/// `horizontalPins`/`horizontalSizing`, `resizingConstraint` stays 63). +#[test] +fn pins_and_sizing_resize_instances() { + let mut r = rect("R", 20.0, 30.0, 100.0, 40.0); + r["horizontalPins"] = json!(1); // left + r["horizontalSizing"] = json!(0); // fixed + r["verticalPins"] = json!(0); + r["verticalSizing"] = json!(3); // relative + let mut m = master("M", "S", "Box", 200.0, 100.0, vec![r]); + m["groupBehavior"] = json!(1); + let (doc, _) = { + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + import(&z, "f").unwrap() + }; + let inst = doc.nodes().find(|n| n.name == "I").unwrap(); + let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap(); + let b = doc.world_bounds(r.id).unwrap(); + assert!((b.x0 - 20.0).abs() < 0.5 && (b.width() - 100.0).abs() < 0.5, "{b:?}"); + assert!((b.y0 - 18.0).abs() < 0.5 && (b.height() - 24.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn graphics_scale_their_layers() { + let mut r = rect("R", 20.0, 30.0, 100.0, 40.0); + r["horizontalPins"] = json!(5); + r["horizontalSizing"] = json!(0); + let mut m = master("M", "S", "Glyph", 200.0, 100.0, vec![r]); + m["groupBehavior"] = json!(2); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 400.0, 200.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + let (doc, _) = import(&z, "f").unwrap(); + let inst = doc.nodes().find(|n| n.name == "I").unwrap(); + let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap(); + let b = doc.world_bounds(r.id).unwrap(); + assert!((b.x0 - 40.0).abs() < 0.5 && (b.width() - 200.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn border_sides() { + let r = styled( + rect("Sided", 0.0, 0.0, 40.0, 40.0), + "borders", + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 8, + "sides": {"_class": "MSImmutableStyleBorderSides", "leftInset": 7, "rightInset": 7, "topInset": 0, "bottomInset": 5}}]), + ); + // A side is as thick as the border less its inset. + let (doc, _) = open(vec![r]); + assert_eq!(node(&doc, "Sided").stroke.sides, Some([8.0, 1.0, 3.0, 1.0])); +} + +#[test] +fn swatches_bind_solid_fills_only() { + let mut d = document(&["P"]); + d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW", "name": "Brand", "value": color(0.0, 0.0, 1.0, 1.0)}]}); + let mut g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 1.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]}}); + g["color"]["swatchID"] = json!("SW"); + let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![styled(rect("G", 0.0, 0.0, 10.0, 10.0), "fills", json!([g]))])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(!warnings.iter().any(|w| w.contains("not supported")), "{warnings:?}"); +} + +/// Sketch 2025+ masters keep their background, corners and stack in their style and layout, and +/// their instances show the same. +#[test] +fn masters_and_instances_keep_frame_styles_and_stacks() { + let mut m = master("M", "S", "Button", 120.0, 44.0, vec![rect("Icon", 0.0, 0.0, 20.0, 20.0), rect("Label", 0.0, 0.0, 60.0, 20.0)]); + m["groupBehavior"] = json!(1); + m["clippingBehavior"] = json!(2); + m["style"]["fills"] = json!([solid_fill(0.0, 0.0, 1.0, 1.0)]); + m["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [8], "style": 1, "smoothing": 0.6}); + let m = stack(m, 0, 1, 1, 10.0, [16.0, 12.0, 16.0, 12.0]); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 120.0, 44.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + let (doc, _) = import(&z, "f").unwrap(); + let main = doc.nodes().find(|n| matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + for n in [main, inst] { + assert_eq!(n.fills.len(), 1, "{}", n.name); + assert_eq!(n.corners.radii, [8.0; 4], "{}", n.name); + assert!(n.layout.is_some(), "{}", n.name); + assert!(!n.clip, "{}", n.name); + let order: Vec<&str> = n.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(order, ["Label", "Icon"], "{}", n.name); + } +} + +fn solid_of(n: &Node) -> Option<[f32; 3]> { + n.fills.iter().find_map(|p| match &p.kind { + figcraft_doc::color::PaintKind::Solid { color } => Some([color.r, color.g, color.b]), + _ => None, + }) +} + +fn open_symbols(layers: Vec, masters: Vec) -> Document { + let z = sketch_zip("2026.3.1", 196, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap().0 +} + +fn in_instance<'d>(doc: &'d Document, inst: &str, name: &str) -> &'d Node { + let i = doc.nodes().find(|n| n.name == inst && matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no {inst}")); + doc.subtree(i.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name} in {inst}")) +} + +/// A graphic's fill tints its layers (it's no background); an instance's fill, or a `fillColor` +/// override, tints the instance's layers. +#[test] +fn fills_of_graphics_and_instances_are_tints() { + let glyph = styled(rect("Glyph", 0.0, 0.0, 24.0, 24.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![glyph]); + icon["groupBehavior"] = json!(2); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + icon["style"]["fills"] = json!([tint]); + let nested = instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[]); + let mut button = master("MB", "SB", "Button", 100.0, 40.0, vec![nested]); + button["groupBehavior"] = json!(1); + let own = instance("Own", "SI", 0.0, 0.0, 24.0, 24.0, &[("Own_fillColor", color(0.0, 0.0, 1.0, 1.0))]); + let via = instance("Via", "SB", 0.0, 100.0, 100.0, 40.0, &[("N_fillColor", color(0.0, 1.0, 0.0, 1.0))]); + let doc = open_symbols(vec![own, via], vec![icon, button]); + let main = doc.nodes().find(|n| n.name == "Icon" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + assert!(main.fills.is_empty(), "a graphic's fill isn't its background"); + let in_main = doc.subtree(main.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "Glyph").unwrap(); + assert_eq!(solid_of(in_main), Some([1.0, 0.0, 0.0])); + assert_eq!(solid_of(in_instance(&doc, "Own", "Glyph")), Some([0.0, 0.0, 1.0])); + assert!(doc.nodes().find(|n| n.name == "Own").unwrap().fills.is_empty()); + assert_eq!(solid_of(in_instance(&doc, "Via", "Glyph")), Some([0.0, 1.0, 0.0])); +} + +#[test] +fn text_color_overrides() { + let label = text("Label", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + let m = master("M", "S", "Button", 100.0, 40.0, vec![label]); + let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_textColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); + let n = in_instance(&doc, "I", "Label"); + let red = |p: &figcraft_doc::color::Paint| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0); + let NodeKind::Text(td) = &n.kind else { panic!("not text") }; + assert!(n.fills.iter().any(red) || td.style.fills.as_ref().is_some_and(|f| f.iter().any(red)), "{:?} {:?}", n.fills, td.style.fills); +} + +#[test] +fn blurs_list() { + let blur = |ty: i64, r: f64| json!({"_class": "blur", "isEnabled": true, "type": ty, "radius": r, "center": "{0.5, 0.5}", "motionAngle": 25}); + let g = styled(rect("Glow", 0.0, 0.0, 40.0, 40.0), "blurs", json!([blur(0, 90.0)])); + let m = styled(rect("Comet", 50.0, 0.0, 40.0, 4.0), "blurs", json!([blur(1, 6.0)])); + let b = styled(rect("Glass", 100.0, 0.0, 40.0, 40.0), "blurs", json!([blur(3, 10.0)])); + let (doc, warnings) = open(vec![g, m, b]); + assert!(matches!(node(&doc, "Glow").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_))); + assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::BackgroundBlur(_))); + assert!(matches!(node(&doc, "Comet").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_)), "motion blur drawn as a layer blur"); + assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); +} + +#[test] +fn text_sizing_2025() { + let mut fixed = text("Fixed", 0.0, 0.0, 100.0, 40.0, "Wraps here", &[(0, 10, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + fixed.as_object_mut().unwrap().remove("textBehaviour"); + fixed["horizontalSizing"] = json!(0); + fixed["verticalSizing"] = json!(1); + let mut fit = text("Fit", 0.0, 50.0, 100.0, 20.0, "Grows", &[(0, 5, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + fit.as_object_mut().unwrap().remove("textBehaviour"); + fit["horizontalSizing"] = json!(1); + fit["verticalSizing"] = json!(1); + let (doc, _) = open(vec![fixed, fit]); + let auto = |n: &str| match &node(&doc, n).kind { + NodeKind::Text(td) => td.auto_resize, + _ => panic!("not text"), + }; + assert_eq!(auto("Fixed"), figcraft_doc::TextAutoResize::Height); + assert_eq!(auto("Fit"), figcraft_doc::TextAutoResize::WidthAndHeight); +} + +#[test] +fn stacks_are_no_smart_layout_note() { + let (_, warnings) = open(vec![stack(frame_group("Row", 0.0, 0.0, 100.0, 40.0, vec![rect("A", 0.0, 0.0, 10.0, 10.0)]), 0, 0, 0, 0.0, [0.0; 4])]); + assert!(!warnings.iter().any(|w| w.contains("Smart Layout")), "{warnings:?}"); +} + +/// Sketch 2025+: a fill with `layeringType` 1 is a tint (of a frame or master too), 0 a background. +#[test] +fn layering_type_decides_tint_or_background() { + let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut tint = solid_fill(0.0, 0.0, 1.0, 1.0); + tint["layeringType"] = json!(1); + let mut bar = master("MB", "SB", "Bar", 100.0, 20.0, vec![dot]); + bar["groupBehavior"] = json!(1); + bar["style"]["fills"] = json!([tint]); + let mut bg = solid_fill(1.0, 0.0, 0.0, 1.0); + bg["layeringType"] = json!(0); + let mut btn = master("MC", "SC", "Btn", 100.0, 20.0, vec![]); + btn["groupBehavior"] = json!(1); + btn["style"]["fills"] = json!([bg]); + let doc = open_symbols(vec![instance("B", "SB", 0.0, 0.0, 100.0, 20.0, &[]), instance("C", "SC", 0.0, 50.0, 100.0, 20.0, &[])], vec![bar, btn]); + let b = doc.nodes().find(|n| n.name == "B").unwrap(); + assert!(b.fills.is_empty(), "a tint isn't a background"); + assert_eq!(solid_of(in_instance(&doc, "B", "Dot")), Some([0.0, 0.0, 1.0])); + assert_eq!(solid_of(doc.nodes().find(|n| n.name == "C").unwrap()), Some([1.0, 0.0, 0.0])); +} + +#[test] +fn inner_shadows_in_the_shadow_list() { + let mut sh = + json!({"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.5)}); + sh["isInnerShadow"] = json!(true); + let (doc, _) = open(vec![styled(rect("Inset", 0.0, 0.0, 40.0, 40.0), "shadows", json!([sh]))]); + assert!(matches!(node(&doc, "Inset").effects[0].kind, figcraft_doc::EffectKind::InnerShadow(_))); +} + +#[test] +fn visibility_overrides() { + let m = master("M", "S", "Input", 100.0, 40.0, vec![rect("Label", 0.0, 0.0, 50.0, 10.0)]); + let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_isVisible", json!("0"))])], vec![m]); + assert!(!in_instance(&doc, "I", "Label").visible); +} + +/// An instance that fits its content hugs it even when its master has a fixed size. +#[test] +fn instance_sizing_is_its_own() { + let m = stack(master("M", "S", "Chip", 60.0, 24.0, vec![rect("A", 0.0, 0.0, 20.0, 20.0)]), 0, 0, 1, 0.0, [0.0; 4]); + let mut i = instance("I", "S", 0.0, 0.0, 60.0, 24.0, &[]); + i["horizontalSizing"] = json!(1); + let doc = open_symbols(vec![i], vec![m]); + let n = doc.nodes().find(|n| n.name == "I").unwrap(); + assert_eq!(n.layout.as_ref().map(|_| format!("{:?}", n.child_layout.sizing_h)), Some("Hug".to_string())); +} + +/// A tint keeps each layer's own opacity. +#[test] +fn tints_keep_opacity() { + let faint = styled(rect("Faint", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 0.25)])); + let mut g = group("Tinted", 0.0, 0.0, 10.0, 10.0, vec![faint]); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + g["groupBehavior"] = json!(0); + g["style"] = json!({"_class": "style", "fills": [tint]}); + let (doc, _) = open(vec![g]); + let f = &node(&doc, "Faint").fills[0]; + let figcraft_doc::color::PaintKind::Solid { color } = &f.kind else { panic!("not solid") }; + assert_eq!((color.r, color.g), (1.0, 0.0)); + assert!((color.a * f.opacity - 0.25).abs() < 1e-3, "{color:?} {}", f.opacity); + assert!(node(&doc, "Tinted").fills.is_empty()); +} + +#[test] +fn glass_and_progressive_blurs() { + let glass = json!({"_class": "blur", "isEnabled": true, "type": 4, "radius": 10, "distortion": 0.6, "depth": 0.7, "center": "{0.5, 0.5}"}); + let prog = json!({"_class": "blur", "isEnabled": true, "type": 3, "radius": 24, "isProgressive": true, "center": "{0.5, 0.5}", + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let (doc, warnings) = open(vec![ + styled(rect("Glass", 0.0, 0.0, 40.0, 40.0), "blurs", json!([glass])), + styled(rect("Prog", 50.0, 0.0, 40.0, 40.0), "blurs", json!([prog])), + ]); + assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::Glass(_)), "{warnings:?}"); + let figcraft_doc::EffectKind::BackgroundBlur(b) = &node(&doc, "Prog").effects[0].kind else { panic!("not a background blur") }; + assert_eq!(b.blur_type, figcraft_doc::BlurType::Progressive); + assert_eq!((b.start_radius, b.radius), (24.0, 0.0)); +} + +/// Angled (2), inside-square (3) and inside-arc (4) corners make the rectangle a path of that outline. +#[test] +fn cut_corners_become_paths() { + let cut = |name: &str, style: i64| { + let mut r = rect(name, 0.0, 0.0, 100.0, 80.0); + r["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [10], "style": style, "smoothing": 0.6}); + r + }; + let (doc, _) = open(vec![cut("Angled", 2), cut("Square", 3), cut("Arc", 4)]); + let net = |n: &str| match &node(&doc, n).kind { + NodeKind::Vector { network } => network.clone(), + other => panic!("{n}: {other:?}"), + }; + let a = net("Angled"); + assert_eq!(a.vertices.len(), 8); + assert!(a.vertices.iter().any(|v| (v.x, v.y) == (0.0, 10.0)) && a.vertices.iter().any(|v| (v.x, v.y) == (10.0, 0.0))); + let s = net("Square"); + assert_eq!(s.vertices.len(), 12); + assert!(s.vertices.iter().any(|v| (v.x, v.y) == (10.0, 10.0)) && s.vertices.iter().any(|v| (v.x, v.y) == (90.0, 70.0))); + let arc = net("Arc"); + assert_eq!(arc.vertices.len(), 8); + assert!(arc.segments.iter().any(|g| g.ta != (0.0, 0.0)), "the corners are arcs"); +} + +/// Tints and colour overrides that aren't colours, and fills that aren't objects, are junk: left +/// alone, never a panic. +#[test] +fn junk_tints_and_colour_overrides() { + let child = styled(rect("Child", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut g = group("G", 0.0, 0.0, 10.0, 10.0, vec![child.clone()]); + g["style"] = json!({"_class": "style", "fills": [{"_class": "fill", "isEnabled": true, "fillType": 0, "layeringType": 1, "color": 5}]}); + let (_, _) = open(vec![g]); + let mut odd = rect("L", 0.0, 0.0, 10.0, 10.0); + odd["style"]["fills"] = json!([true]); + let m = master("M", "S", "Odd", 10.0, 10.0, vec![odd]); + let _ = open_symbols(vec![instance("I", "S", 0.0, 0.0, 10.0, 10.0, &[("L_fillColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); +} + +/// Tinting copies a group's layers: the copies count against the import's work budget, so nested +/// tinted groups of large layers can't make the import copy without end; past the budget, tints +/// are skipped with a note. +#[test] +fn nested_tints_are_budgeted() { + // Text that doesn't compress, so the page stays within the archive's size checks. + let mut seed = 0x2545_f491_u32; + let big: String = (0..1_000_000) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + char::from(b'a' + (seed % 26) as u8) + }) + .collect(); + let mut l = styled(rect("Leaf", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + for i in 0..35 { + let mut pad = rect(&big, 0.0, 0.0, 1.0, 1.0); + pad["do_objectID"] = json!(format!("pad{i}")); + let mut g = group(&format!("G{i}"), 0.0, 0.0, 10.0, 10.0, vec![l, pad]); + let mut t = solid_fill(1.0, 0.0, 0.0, 1.0); + t["layeringType"] = json!(1); + g["style"] = json!({"_class": "style", "fills": [t]}); + l = g; + } + let (_, warnings) = open(vec![l]); + assert!(warnings.iter().any(|w| w.contains("tint")), "{warnings:?}"); +} + +/// A faded layer in a stack keeps its place in the stack: the group the fade makes takes the +/// layer's id, and with it its sizing. +#[test] +fn faded_stack_items_keep_their_sizing() { + let fade = json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let mut item = styled(rect("Item", 0.0, 0.0, 50.0, 20.0), "contextSettings", fade); + item["horizontalSizing"] = json!(2); + let (doc, _) = open(vec![stack(frame_group("Row", 0.0, 0.0, 200.0, 20.0, vec![item]), 0, 0, 0, 0.0, [0.0; 4])]); + let row = node(&doc, "Row"); + let wrap = doc.node(row.children[0]).unwrap(); + assert_eq!(format!("{:?}", wrap.child_layout.sizing_h), "Fill", "{:?}", wrap.child_layout); +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs new file mode 100644 index 000000000..985541b16 --- /dev/null +++ b/crates/format/src/sketch/mod.rs @@ -0,0 +1,277 @@ +//! Import of `.sketch` files saved by Sketch 48 or newer (best effort), written from the public +//! description of the format only. +//! +//! A `.sketch` file is a zip archive holding `document.json` (the document: its pages in order, +//! shared styles, swatches and library symbols copied into it), `meta.json` (the app version and +//! format version that saved it), one `pages/.json` per page (the layer tree), `images/` and, +//! in newer files, `fonts/`. Every object carries its kind in `_class`. [`tree`] maps the layers +//! onto the REST API file JSON shape and the REST importer builds the document, so this import +//! shares its mapping, budgets and import report with `.fig` and file JSON imports. Files older +//! than Sketch 48 (a SQLite database before 43, text as binary property lists until 47) are +//! refused with a message saying how to convert them. Anything not understood is skipped with a +//! warning; malformed input never panics. + +#[cfg(test)] +mod fixture; +#[cfg(test)] +mod frames_tests; +mod path; +#[cfg(test)] +mod path_tests; +mod proto; +#[cfg(test)] +mod proto_tests; +mod shared; +#[cfg(test)] +mod shared_tests; +mod stack; +mod style; +#[cfg(test)] +mod style_tests; +mod symbols; +#[cfg(test)] +mod symbols_tests; +#[cfg(test)] +mod tests; +mod text; +#[cfg(test)] +mod text_tests; +mod tree; +#[cfg(test)] +mod tree_tests; + +use std::collections::BTreeSet; + +use figcraft_doc::Document; +use serde_json::{Value, json}; + +use crate::FormatError; + +/// The oldest Sketch whose files open: from 48 on, text is plain JSON too. +pub const MIN_SKETCH: u32 = 48; +/// The newest `meta.json` format version checked against real files (Sketch 83); newer files +/// open with a warning. +pub const NEWEST_TESTED_VERSION: u64 = 139; +/// What opening a file saved by an older Sketch says. +pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again"; +/// Largest JSON member read (a real page of a few thousand layers takes 33 MB). +const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024; +/// Most bytes JSON members may unpack to, per compressed byte (real files pack 5–20 times; a +/// zero-filled array a thousand times), with a floor for small members. +const MAX_JSON_RATIO: u64 = 100; +const MIN_JSON_ALLOWANCE: u64 = 16 * 1024 * 1024; +/// Most JSON read from one file in all. +#[cfg(not(target_pointer_width = "32"))] +const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024; +/// Largest image member read. +const MAX_IMAGE: u64 = 256 * 1024 * 1024; +/// Most pages read from one file. +const MAX_PAGES: usize = 1000; + +/// Whether `bytes` look like a `.sketch` file: a zip holding `document.json` and `meta.json`, or +/// the SQLite database Sketch wrote before version 43 (so that it gets the "too old" message). +pub fn is_sketch(bytes: &[u8]) -> bool { + bytes.starts_with(b"SQLite format 3\0") + || (bytes.starts_with(b"PK") + && zip::ZipArchive::new(std::io::Cursor::new(bytes)).is_ok_and(|z| { + let names: BTreeSet<&str> = z.file_names().collect(); + names.contains("document.json") && names.contains("meta.json") + })) +} + +/// Finds the installed face a PostScript name belongs to: its family and style. +pub type FontLookup = Box Option<(String, String)>>; + +/// Import `bytes` when they are a `.sketch` file; `None` when they are something else. +pub fn try_import(bytes: &[u8], name: &str) -> Option), FormatError>> { + try_import_with_fonts(bytes, name, None) +} + +/// [`try_import`] with `fonts` finding the installed faces the file's fonts (PostScript names) +/// belong to; families of fonts it doesn't find are read from their names. +pub fn try_import_with_fonts(bytes: &[u8], name: &str, fonts: Option) -> Option), FormatError>> { + is_sketch(bytes).then(|| import_with_fonts(bytes, name, fonts)) +} + +/// Build a document from a `.sketch` file. Returns it with the warnings about what was skipped or +/// approximated. `name`: the document name. +pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), FormatError> { + import_with_fonts(bytes, name, None) +} + +/// [`import`] with a font lookup (see [`try_import_with_fonts`]). +pub fn import_with_fonts(bytes: &[u8], name: &str, fonts: Option) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len()), fonts) +} + +/// [`import`] reading pages of at most `max_member` bytes of JSON each. +#[cfg(test)] +pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len()), None) +} + +/// [`import`] with at most `max_nodes` layers. +#[cfg(test)] +pub(crate) fn import_within_nodes(bytes: &[u8], name: &str, max_nodes: usize) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes, None) +} + +fn import_parts( + bytes: &[u8], + name: &str, + max_member: u64, + max_nodes: usize, + fonts: Option, +) -> Result<(Document, Vec), FormatError> { + if bytes.starts_with(b"SQLite format 3\0") { + return Err(FormatError::Io(TOO_OLD.into())); + } + let archive = Archive::open(bytes, max_member)?; + let map = tree::to_rest(&archive, name, max_nodes, fonts); + if map.too_old { + return Err(FormatError::Io(TOO_OLD.into())); + } + let mut warnings = archive.warnings.clone(); + warnings.extend(map.warnings); + if let Some(note) = embedded_fonts(&archive.document) { + warnings.push(note); + } + let images = |r: &str| archive.image(r); + let (mut doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; + if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { + doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; + } + warnings.extend(more); + Ok((doc, warnings)) +} + +/// The note about fonts the file carries (Sketch 7x and newer can embed them, as `fontReferences` +/// with `fontData`): FigCraft doesn't use fonts from inside documents, so their text shows in +/// installed fonts. To do once `.figcraft` documents can carry fonts (loading them only for the +/// session would lose them on the first save and reopen). +fn embedded_fonts(document: &Value) -> Option { + let refs = document.get("fontReferences").and_then(Value::as_array)?; + // A reference without font data only names a font the document uses. + let families: BTreeSet = refs + .iter() + .filter(|r| r.get("fontData").is_some()) + .filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)) + .map(|f| f.chars().take(64).collect()) + .take(20) + .collect(); + if families.is_empty() { + return None; + } + let list: Vec<&str> = families.iter().map(String::as_str).collect(); + Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", "))) +} + +fn corrupt(msg: impl Into) -> FormatError { + FormatError::Corrupt(format!("Sketch file: {}", msg.into())) +} + +/// The parts of a `.sketch` archive: the document, the pages in document order and the images. +pub(crate) struct Archive<'b> { + pub document: Value, + /// `meta.json`'s format version (0 when missing). + pub version: u64, + /// `(id, page JSON)` in the document's order. + pub pages: Vec<(String, Value)>, + /// Problems met while reading (pages skipped). + pub warnings: Vec, + zip: zip::ZipArchive>, +} + +impl<'b> Archive<'b> { + fn open(bytes: &'b [u8], max_member: u64) -> Result { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?; + let mut total = 0u64; + let meta = json_member(&mut z, "meta.json", MAX_JSON_MEMBER, &mut total)?; + if !meta.is_object() { + return Err(corrupt("meta.json is not an object")); + } + if app_major(&meta).is_some_and(|v| v < MIN_SKETCH) { + return Err(FormatError::Io(TOO_OLD.into())); + } + let mut warnings = Vec::new(); + if meta.get("version").and_then(Value::as_u64).is_some_and(|v| v > NEWEST_TESTED_VERSION) { + warnings.push("the file was saved by a newer Sketch than the import was checked with; some layers may differ".to_string()); + } + // A document.json that can't be read (nested too deep, damaged) loses its shared styles and + // library copies, not the pages: they are then taken in archive order. + let document = match json_member(&mut z, "document.json", MAX_JSON_MEMBER, &mut total) { + Ok(d) if d.is_object() => d, + other => { + let why = other.err().map_or_else(|| "not an object".to_string(), |e| e.to_string()); + warnings.push(format!( + "the document's shared styles and library symbols were skipped: {}", + why.trim_start_matches("not a valid FigCraft file: ") + )); + let pages: Vec = z + .file_names() + .filter_map(|n| n.strip_prefix("pages/")?.strip_suffix(".json")) + .map(|id| json!({"_ref": format!("pages/{id}")})) + .collect(); + json!({"pages": pages}) + } + }; + let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut pages = Vec::new(); + for r in refs.iter().take(MAX_PAGES) { + let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else { + warnings.push("pages the document lists without a valid reference were skipped".into()); + continue; + }; + match json_member(&mut z, &format!("pages/{id}.json"), max_member, &mut total) { + Ok(page) if page.is_object() => pages.push((id.to_string(), page)), + Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()), + Err(e) => warnings.push(format!("a page was skipped: {}", e.to_string().trim_start_matches("not a valid FigCraft file: "))), + } + } + if refs.len() > MAX_PAGES { + warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); + } + let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0); + Ok(Archive { document, version, pages, warnings, zip: z }) + } + + /// The bytes of the image a layer references (`images/`). + pub(crate) fn image(&self, r: &str) -> Option> { + let name = r.strip_prefix("images/").unwrap_or(r); + if !valid_id(name.split('.').next().unwrap_or("")) || name.split('.').count() > 2 { + return None; + } + // A clone shares the parsed directory and the bytes; the resolver only has shared access. + let mut z = self.zip.clone(); + crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok() + } +} + +/// JSON member `name`, refused before unpacking when it would be larger than `limit`, than +/// [`MAX_JSON_RATIO`] times what it takes in the archive (past a floor), or than what is left of +/// the file's [`MAX_JSON_TOTAL`] (`total`: read so far). +fn json_member(z: &mut zip::ZipArchive>, name: &str, limit: u64, total: &mut u64) -> Result { + let (size, packed) = { + let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?; + (f.size(), f.compressed_size()) + }; + let allowed = limit.min(packed.saturating_mul(MAX_JSON_RATIO).max(MIN_JSON_ALLOWANCE)).min(MAX_JSON_TOTAL.saturating_sub(*total)); + if size > allowed { + return Err(corrupt(format!("{name} is too large"))); + } + let data = crate::read_member(z, name, allowed)?; + *total += data.len() as u64; + serde_json::from_slice(&data).map_err(|e| corrupt(format!("{name}: {e}"))) +} + +/// The major version of the Sketch that saved the file (`meta.json` `appVersion`, "50.2" → 50). +fn app_major(meta: &Value) -> Option { + meta.get("appVersion").and_then(Value::as_str)?.split('.').next()?.trim().parse().ok() +} + +/// Ids name archive members: letters, digits, `-` and `_` only, never a path. +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs new file mode 100644 index 000000000..24ceca15f --- /dev/null +++ b/crates/format/src/sketch/path.rs @@ -0,0 +1,387 @@ +//! Sketch curve points → vector networks; shape groups → shapes and boolean operations. +//! +//! A path's `points` are `curvePoint`s whose `point`, `curveFrom` (the handle leaving the point) +//! and `curveTo` (the handle arriving at it) are `"{x, y}"` strings in 0–1 of the layer's frame; +//! values outside that range are allowed. Sketch 51 and older wrap every shape in a `shapeGroup` +//! that carries the style; its children combine left to right, each with its own +//! `booleanOperation` (0 union, 1 subtract, 2 intersect, 3 difference). Children with −1 (none) +//! don't combine: they join the path before them into one compound path, filled by the winding +//! rule (`windingRule` on the layer, 1 even-odd: inner paths are holes). Sketch fills an open path +//! as if it were closed. + +use figcraft_doc::geom::{Affine, polygon_points, star_points}; +use figcraft_doc::{Region, Segment, VectorNetwork, Vertex, WindingRule}; +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, base, layer, layers, local, size}; + +/// Farthest a point or handle may be from its layer (the REST importer's own range). +const MAX_COORD: f64 = 1e7; +/// Most points read from one path. +const MAX_POINTS: usize = 100_000; + +/// What a path layer becomes. +pub(crate) enum Shape { + Vector(VectorNetwork), + Star { count: u32, ratio: f64 }, + Polygon { count: u32 }, +} + +/// `"{x, y}"` → `(x, y)`, both finite. +pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> { + let inner = s.trim().strip_prefix('{')?.strip_suffix('}')?; + let (x, y) = inner.split_once(',')?; + let n = |t: &str| t.trim().parse::().ok().filter(|v| v.is_finite()); + Some((n(x)?, n(y)?)) +} + +/// A layer's winding rule: on the layer (Sketch 51 and older), else in its style. +pub(crate) fn winding(l: &Value) -> WindingRule { + let rule = l.get("windingRule").or_else(|| l.get("style").and_then(|s| s.get("windingRule"))); + match rule.and_then(Value::as_i64) { + Some(1) => WindingRule::EvenOdd, + _ => WindingRule::NonZero, + } +} + +/// Whether a style shows any fill or border. +fn shows(style: Option<&Value>, key: &str) -> bool { + style + .and_then(|s| s.get(key)) + .and_then(Value::as_array) + .is_some_and(|l| l.iter().any(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false))) +} + +/// The network of a path layer of `w`×`h` (`None` when it has no usable points). `fill_open`: an +/// open path gets a closing segment and a fill region, as Sketch fills it. +pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -> Option { + let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + if pts.len() > MAX_POINTS { + cx.warn(format!("paths with more than {MAX_POINTS} points were cut")); + } + // Far points stay within the importer's coordinate range (a product could overflow). + let fit = |v: f64| if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 }; + let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (fit(x * w), fit(y * h))); + let mut vertices = Vec::new(); + // Per vertex: its handles (relative to it) when it has them. + let mut handles: Vec<((f64, f64), (f64, f64))> = Vec::new(); + for p in pts.iter().take(MAX_POINTS) { + let Some((x, y)) = at(p, "point") else { + cx.warn("path points that couldn't be read were skipped"); + continue; + }; + let curved = p.get("curveMode").and_then(Value::as_i64).is_some_and(|m| m != 1); + let has = |flag: &str| p.get(flag).and_then(Value::as_bool).unwrap_or(curved); + let handle = |flag: &str, key: &str| if has(flag) { at(p, key).map_or((0.0, 0.0), |(hx, hy)| (hx - x, hy - y)) } else { (0.0, 0.0) }; + handles.push((handle("hasCurveFrom", "curveFrom"), handle("hasCurveTo", "curveTo"))); + let mut v = Vertex::new(x, y); + v.radius = p.get("cornerRadius").and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, 1e6)); + vertices.push(v); + } + let n = vertices.len(); + if n == 0 { + return None; + } + let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false); + let closed = n > 1 && (!open || (fill_open && n > 2)); + let count = if closed { n } else { n - 1 }; + let segments: Vec = (0..count) + .map(|i| { + let j = (i + 1) % n; + // The edge that closes an open path for its fill is straight. + let closing = open && j == 0; + let ta = if closing { (0.0, 0.0) } else { handles.get(i).map_or((0.0, 0.0), |h| h.0) }; + let tb = if closing { (0.0, 0.0) } else { handles.get(j).map_or((0.0, 0.0), |h| h.1) }; + Segment { a: i, b: j, ta, tb } + }) + .collect(); + let winding = winding(l); + let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() }; + Some(VectorNetwork { vertices, segments, regions }) +} + +/// Rectangle `l` with angled (corner style 2), inside-square (3) or inside-arc (4) corners in its +/// style (Sketch 2025 and newer) as an edited rectangle whose points draw that outline, the +/// corners' `radii` (top left, top right, bottom right, bottom left; repeating) being how far +/// each cut reaches along both edges. `None` for any other layer. +pub(crate) fn cut_corners(l: &Value) -> Option { + if l.get("_class").and_then(Value::as_str) != Some("rectangle") || l.get("edited").and_then(Value::as_bool) == Some(true) { + return None; + } + let c = l.get("style")?.get("corners")?; + let style = c.get("style").and_then(Value::as_i64).filter(|s| (2..=4).contains(s))?; + let radii: Vec = c.get("radii")?.as_array()?.iter().filter_map(Value::as_f64).filter(|r| r.is_finite()).take(4).collect(); + if radii.iter().all(|r| *r <= 0.0) { + return None; + } + let (w, h) = size(l); + if w <= 0.0 || h <= 0.0 { + return None; + } + // Arc handles: a quarter circle's. + const K: f64 = 0.552_284_749_8; + let fmt = |(x, y): (f64, f64)| format!("{{{}, {}}}", x / w, y / h); + let curve = |p: (f64, f64), from: Option<(f64, f64)>, to: Option<(f64, f64)>| { + json!({"_class": "curvePoint", "cornerRadius": 0, "point": fmt(p), "curveMode": if from.or(to).is_some() { 4 } else { 1 }, + "hasCurveFrom": from.is_some(), "hasCurveTo": to.is_some(), "curveFrom": fmt(from.unwrap_or(p)), "curveTo": fmt(to.unwrap_or(p))}) + }; + // Corners clockwise from the top left, each with the directions to the edge before and after. + let corners = [ + ((0.0, 0.0), (0.0, 1.0), (1.0, 0.0)), + ((w, 0.0), (-1.0, 0.0), (0.0, 1.0)), + ((w, h), (0.0, -1.0), (-1.0, 0.0)), + ((0.0, h), (1.0, 0.0), (0.0, -1.0)), + ]; + let mut points = Vec::new(); + for (i, ((cx, cy), (px, py), (nx, ny))) in corners.into_iter().enumerate() { + let r = radii.get(i % radii.len()).copied().unwrap_or(0.0).clamp(0.0, w.min(h) / 2.0); + if r <= 0.0 { + points.push(curve((cx, cy), None, None)); + continue; + } + let a = (cx + r * px, cy + r * py); + let b = (cx + r * nx, cy + r * ny); + match style { + 2 => points.extend([curve(a, None, None), curve(b, None, None)]), + 3 => points.extend([curve(a, None, None), curve((cx + r * (px + nx), cy + r * (py + ny)), None, None), curve(b, None, None)]), + // A quarter circle about the corner, bulging into the shape. + _ => points + .extend([curve(a, Some((a.0 + K * r * nx, a.1 + K * r * ny)), None), curve(b, None, Some((b.0 + K * r * px, b.1 + K * r * py)))]), + } + } + let mut out = l.clone(); + out["edited"] = json!(true); + out["isClosed"] = json!(true); + out["points"] = Value::Array(points); + if let Some(st) = out.get_mut("style").and_then(Value::as_object_mut) { + st.remove("corners"); + } + Some(out) +} + +/// A path, star, polygon or triangle layer: a star or polygon while its points are still the +/// generated ones (same as FigCraft's, within half a percent), else an editable vector. +pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { + // Inside a shape group the group's fill decides; a shape of its own, its style. + let style = l.get("style"); + let filled = cx.operand_fill.unwrap_or_else(|| shows(style, "fills")); + let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false); + if open && filled && (shows(style, "borders") || cx.operand_border) { + cx.warn("open paths with both a fill and a border were closed; the border runs along the closing edge too"); + } + let net = network(cx, l, w, h, filled)?; + let class = l.get("_class").and_then(Value::as_str).unwrap_or(""); + let count = l.get("numberOfPoints").and_then(Value::as_u64).filter(|c| (3..=120).contains(c)).and_then(|c| u32::try_from(c).ok()); + let plain = net.vertices.iter().all(|v| v.radius == 0.0) && net.segments.iter().all(|s| s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0)); + let matches = |pts: Vec| { + let tol = 0.005 * w.max(h) + 1e-6; + pts.len() == net.vertices.len() && pts.iter().zip(&net.vertices).all(|(p, v)| (p.x - v.x).abs() <= tol && (p.y - v.y).abs() <= tol) + }; + match (class, count) { + ("star", Some(count)) if plain => { + let ratio = l.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0).map_or(0.5, |r| r.min(1.0)); + if matches(star_points(w, h, count, ratio)) { + return Some(Shape::Star { count, ratio }); + } + } + ("polygon", Some(count)) if plain && matches(polygon_points(w, h, count)) => return Some(Shape::Polygon { count }), + _ => {} + } + Some(Shape::Vector(net)) +} + +/// Write `shape` into `node` (its type and geometry). +pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap) { + match shape { + Shape::Vector(net) => { + node.insert("type".into(), json!("VECTOR")); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + } + Shape::Star { count, ratio } => { + node.insert("type".into(), json!("STAR")); + node.insert("pointCount".into(), json!(count)); + node.insert("innerRadius".into(), json!(ratio)); + } + Shape::Polygon { count } => { + node.insert("type".into(), json!("REGULAR_POLYGON")); + node.insert("pointCount".into(), json!(count)); + } + } +} + +/// A `shapeGroup`: its only child carrying the group's place, name and style; its children joined +/// into one compound path when none of them combines; else boolean operations over runs of +/// children (a run: a child with an operation and the ones without that follow it), mixed +/// operations nesting from the left. +pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let kids = layers(l); + let saved = (cx.operand_fill, cx.operand_border); + cx.operand_border = shows(l.get("style"), "borders"); + let out = if let [only] = kids { + cx.operand_fill = Some(shows(l.get("style"), "fills")); + single(cx, l, only, parent, world, depth, root) + } else { + // Operands of a boolean operation (or a compound path) give area: open ones close. + cx.operand_fill = Some(true); + combined(cx, l, kids, parent, world, (w, h), depth, root) + }; + (cx.operand_fill, cx.operand_border) = saved; + out +} + +/// A shape group of one shape: that shape in the group's place, with its name and style. +fn single(cx: &mut Cx, l: &Value, only: &Value, parent: Affine, world: Affine, depth: usize, root: bool) -> Option { + let mut node = layer(cx, only, world, depth + 1, false)?; + let (cw, ch) = size(only); + let child_world = world * local(only, cw, ch); + let group = base(l, "GROUP", parent, child_world, cw, ch, root); + if let Some(m) = node.as_object_mut() { + for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { + match group.get(key) { + Some(v) => m.insert(key.into(), v.clone()), + None => m.remove(key), + }; + } + let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); + m.insert("visible".into(), json!(visible)); + if l.get("style").is_some() { + super::style::clear(m); + m.remove("styles"); + super::tree::styles(cx, l, cw, ch, m); + super::tree::extras(cx, l, ch, m); + } + super::tree::mask(l, m); + } + Some(node) +} + +/// A shape group of several shapes: one compound path, or boolean operations over runs. +fn combined(cx: &mut Cx, l: &Value, kids: &[Value], parent: Affine, world: Affine, (w, h): (f64, f64), depth: usize, root: bool) -> Option { + // Runs: a child with an operation starts one, children without (−1) join it. + let mut runs: Vec> = Vec::new(); + for (i, c) in kids.iter().enumerate() { + match runs.last_mut() { + Some(run) if i > 0 && c.get("booleanOperation").and_then(Value::as_i64).unwrap_or(-1) < 0 => run.push(c), + _ => runs.push(vec![c]), + } + } + let winding = winding(l); + if let [run] = runs.as_slice() + && let Some(net) = compound(cx, run, winding) + { + // No operations at all: the group is one path. + let mut node = base(l, "VECTOR", parent, world, w, h, root); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + super::tree::mask(l, &mut node); + super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); + return Some(Value::Object(node)); + } + let mut chain: Vec = Vec::new(); + let mut op = "UNION"; + for (i, run) in runs.iter().enumerate() { + let operands: Vec = match run.as_slice() { + [one] => layer(cx, one, world, depth + 2, false).into_iter().collect(), + more => match compound(cx, more, winding) { + Some(net) => { + let mut node = base(more.first().copied().unwrap_or(l), "VECTOR", world, world, w, h, false); + node.remove("id"); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + vec![Value::Object(node)] + } + // Not all paths: each one an operand of its own. + None => more.iter().filter_map(|c| layer(cx, c, world, depth + 2, false)).collect(), + }, + }; + if operands.is_empty() { + continue; + } + let this = run.first().map_or("UNION", |c| boolean_op(c)); + if i > 0 && this != op && chain.len() > 1 { + let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain)); + chain.push(inner); + } + if i > 0 { + op = this; + } + chain.extend(operands); + } + if chain.is_empty() { + return None; + } + let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); + node.insert("booleanOperation".into(), json!(op)); + node.insert("children".into(), Value::Array(chain)); + super::tree::mask(l, &mut node); + super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); + Some(Value::Object(node)) +} + +/// The paths `run` as one network in their group's space, filled by `winding`; `None` when one of +/// them isn't a path (a group, text, …). +fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option { + const PATHS: [&str; 6] = ["shapePath", "rectangle", "oval", "star", "polygon", "triangle"]; + if run.iter().any(|c| !c.get("_class").and_then(Value::as_str).is_some_and(|k| PATHS.contains(&k))) { + return None; + } + let mut out = VectorNetwork::default(); + let mut loops = Vec::new(); + for c in run { + let (cw, ch) = size(c); + let Some(net) = network(cx, c, cw, ch, true) else { continue }; + let xf = local(c, cw, ch); + let [a, b, cc, d, _, _] = xf.as_coeffs(); + let lin = |(x, y): (f64, f64)| (a * x + cc * y, b * x + d * y); + let (v0, s0) = (out.vertices.len(), out.segments.len()); + for v in net.vertices { + let p = xf * figcraft_doc::geom::Point::new(v.x, v.y); + out.vertices.push(Vertex { x: p.x, y: p.y, ..v }); + } + for sg in net.segments { + out.segments.push(Segment { a: sg.a + v0, b: sg.b + v0, ta: lin(sg.ta), tb: lin(sg.tb) }); + } + for r in net.regions { + loops.extend(r.loops.into_iter().map(|lp| lp.into_iter().map(|i| i + s0).collect::>())); + } + } + if out.vertices.is_empty() { + return None; + } + if !loops.is_empty() { + out.regions.push(Region { loops, winding }); + } + Some(out) +} + +/// A boolean operation the shape group doesn't have as a layer of its own: placed like the group. +fn synthetic(l: &Value, op: &str, parent: Affine, world: Affine, w: f64, h: f64, children: Vec) -> Value { + let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, false); + node.remove("id"); + let name = match op { + "SUBTRACT" => "Subtract", + "INTERSECT" => "Intersect", + "EXCLUDE" => "Exclude", + _ => "Union", + }; + node.insert("name".into(), json!(name)); + // Placed where the group is: inside it, that's no move at all. + node.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])); + node.insert("booleanOperation".into(), json!(op)); + node.insert("children".into(), Value::Array(children)); + Value::Object(node) +} + +fn boolean_op(l: &Value) -> &'static str { + match l.get("booleanOperation").and_then(Value::as_i64) { + Some(1) => "SUBTRACT", + Some(2) => "INTERSECT", + Some(3) => "EXCLUDE", + _ => "UNION", + } +} diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs new file mode 100644 index 000000000..70ad95275 --- /dev/null +++ b/crates/format/src/sketch/path_tests.rs @@ -0,0 +1,235 @@ +//! Sketch paths → editable vector networks; shape groups → shapes and boolean operations; stars +//! and polygons. + +use figcraft_doc::{BoolOp, Document, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; +use super::path::parse_point; + +fn open(layers: Vec) -> Document { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap().0 +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn with_op(mut l: Value, op: i64) -> Value { + l["booleanOperation"] = json!(op); + l +} + +#[test] +fn parse_point_accepts_sketch_strings() { + assert_eq!(parse_point("{0.5, 1}"), Some((0.5, 1.0))); + assert_eq!(parse_point("{-0.25,2e-1}"), Some((-0.25, 0.2))); + assert_eq!(parse_point("{1e400, 0}"), None); + assert_eq!(parse_point("garbage"), None); + assert_eq!(parse_point("{0.1}"), None); + assert_eq!(parse_point(""), None); +} + +#[test] +fn triangle_path_is_editable_vector() { + let tri = shape_path("Tri", 10.0, 10.0, 100.0, 100.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true); + let open_line = shape_path("Line", 10.0, 200.0, 100.0, 50.0, &[(0.0, 0.0), (1.0, 1.0)], false); + let doc = open(vec![tri, open_line]); + let NodeKind::Vector { network } = &node(&doc, "Tri").kind else { panic!("not a vector") }; + let pts: Vec<(f64, f64)> = network.vertices.iter().map(|v| (v.x, v.y)).collect(); + assert_eq!(pts, [(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + assert_eq!(network.segments.len(), 3); + assert_eq!(network.regions.len(), 1); + let NodeKind::Vector { network } = &node(&doc, "Line").kind else { panic!("not a vector") }; + assert_eq!(network.segments.len(), 1); + assert!(network.regions.is_empty()); +} + +#[test] +fn curve_handles_and_corner_radius_are_kept() { + let mut p = shape_path("Curve", 0.0, 0.0, 100.0, 100.0, &[(0.0, 0.0), (1.0, 0.0)], false); + p["points"][0]["curveMode"] = json!(2); + p["points"][0]["hasCurveFrom"] = json!(true); + p["points"][0]["curveFrom"] = json!("{0.25, 0.5}"); + p["points"][1]["curveMode"] = json!(2); + p["points"][1]["hasCurveTo"] = json!(true); + p["points"][1]["curveTo"] = json!("{0.75, 0.5}"); + p["points"][1]["cornerRadius"] = json!(6); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Curve").kind else { panic!("not a vector") }; + let s = network.segments[0]; + assert_eq!((s.ta, s.tb), ((25.0, 50.0), (-25.0, 50.0))); + assert_eq!(network.vertices[1].radius, 6.0); +} + +#[test] +fn single_child_shape_group_collapses() { + let inner = with_op(rect("Inner", 0.0, 0.0, 50.0, 30.0), -1); + let doc = open(vec![shape_group("Button", 20.0, 40.0, 50.0, 30.0, vec![inner])]); + let b = node(&doc, "Button"); + assert!(matches!(b.kind, NodeKind::Rectangle)); + assert!(doc.nodes().all(|n| n.name != "Inner")); + let bb = doc.world_bounds(b.id).unwrap(); + assert_eq!((bb.x0, bb.y0, bb.width(), bb.height()), (20.0, 40.0, 50.0, 30.0)); +} + +#[test] +fn mixed_boolean_ops_chain_left() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 10.0, 10.0, 20.0, 20.0), 1); + let c = with_op(rect("C", 60.0, 0.0, 20.0, 20.0), 0); + let doc = open(vec![shape_group("Combined", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]); + let outer = node(&doc, "Combined"); + assert!(matches!(outer.kind, NodeKind::BooleanOp { op: BoolOp::Union })); + let kids: Vec<&figcraft_doc::Node> = outer.children.iter().map(|c| doc.node(*c).unwrap()).collect(); + assert_eq!(kids.len(), 2); + assert!(matches!(kids[0].kind, NodeKind::BooleanOp { op: BoolOp::Subtract })); + assert_eq!(kids[1].name, "C"); + let inner: Vec<&str> = kids[0].children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(inner, ["A", "B"]); + let bb = doc.world_bounds(node(&doc, "B").id).unwrap(); + assert_eq!((bb.x0, bb.y0), (10.0, 10.0)); +} + +#[test] +fn intersect_and_difference_ops() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 2); + let doc = open(vec![shape_group("Both", 0.0, 0.0, 60.0, 60.0, vec![a.clone(), b])]); + assert!(matches!(node(&doc, "Both").kind, NodeKind::BooleanOp { op: BoolOp::Intersect })); + let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 3); + let doc = open(vec![shape_group("Either", 0.0, 0.0, 60.0, 60.0, vec![a, b])]); + assert!(matches!(node(&doc, "Either").kind, NodeKind::BooleanOp { op: BoolOp::Exclude })); +} + +#[test] +fn standalone_shapes_52_plus() { + let doc = open(vec![shape_path("Free", 5.0, 5.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true)]); + assert!(matches!(node(&doc, "Free").kind, NodeKind::Vector { .. })); +} + +#[test] +fn star_and_polygon() { + let unit = |pts: Vec, w: f64, h: f64| -> Vec<(f64, f64)> { pts.iter().map(|p| (p.x / w, p.y / h)).collect() }; + let mut star = shape_path("Star", 0.0, 0.0, 100.0, 80.0, &unit(figcraft_doc::geom::star_points(100.0, 80.0, 5, 0.5), 100.0, 80.0), true); + star["_class"] = json!("star"); + star["numberOfPoints"] = json!(5); + star["radius"] = json!(0.5); + let mut poly = shape_path("Hexagon", 0.0, 100.0, 60.0, 60.0, &unit(figcraft_doc::geom::polygon_points(60.0, 60.0, 6), 60.0, 60.0), true); + poly["_class"] = json!("polygon"); + poly["numberOfPoints"] = json!(6); + let mut edited = star.clone(); + edited["do_objectID"] = json!("Edited"); + edited["name"] = json!("Edited"); + edited["points"][2]["point"] = json!("{0.9, 0.9}"); + let mut tri = shape_path("Triangle", 200.0, 0.0, 50.0, 50.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true); + tri["_class"] = json!("triangle"); + let doc = open(vec![star, poly, edited, tri]); + assert!(matches!(node(&doc, "Star").kind, NodeKind::Star { count: 5, .. })); + if let NodeKind::Star { inner_ratio, .. } = node(&doc, "Star").kind { + assert!((inner_ratio - 0.5).abs() < 1e-9); + } + assert!(matches!(node(&doc, "Hexagon").kind, NodeKind::Polygon { count: 6 })); + assert!(matches!(node(&doc, "Edited").kind, NodeKind::Vector { .. })); + assert!(matches!(node(&doc, "Triangle").kind, NodeKind::Vector { .. })); +} + +#[test] +fn points_outside_unit_are_kept_finite() { + let doc = open(vec![shape_path("Over", 0.0, 0.0, 10.0, 10.0, &[(-0.5, 2.0), (1.0, 0.0), (0.0, 0.0)], true)]); + let NodeKind::Vector { network } = &node(&doc, "Over").kind else { panic!("not a vector") }; + assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); + assert_eq!((network.vertices[0].x, network.vertices[0].y), (-5.0, 20.0)); +} + +#[test] +fn even_odd_winding() { + let mut p = shape_path("Holes", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["style"]["windingRule"] = json!(1); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Holes").kind else { panic!("not a vector") }; + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); +} + +#[test] +fn huge_points_stay_finite() { + let mut p = shape_path("Huge", 0.0, 0.0, 1e6, 1e6, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["points"][1]["point"] = json!("{1e308, 0}"); + p["points"][1]["curveMode"] = json!(2); + p["points"][1]["curveFrom"] = json!("{-1e308, 1e308}"); + let doc = open(vec![p]); + if let NodeKind::Vector { network } = &node(&doc, "Huge").kind { + assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); + assert!(network.segments.iter().all(|s| [s.ta.0, s.ta.1, s.tb.0, s.tb.1].iter().all(|c| c.is_finite()))); + } +} + +/// Sketch 51 and older: a shape group whose children have no operation (−1) is one compound path, +/// filled by the group's winding rule (stored on the layer itself). +#[test] +fn compound_shape_group_has_holes() { + let outer = with_op(rect("Outer", 0.0, 0.0, 100.0, 100.0), -1); + let inner = with_op(rect("Inner", 25.0, 25.0, 50.0, 50.0), -1); + let mut g = shape_group("Ring", 10.0, 10.0, 100.0, 100.0, vec![outer, inner]); + g["windingRule"] = json!(1); + let doc = open(vec![g]); + let n = node(&doc, "Ring"); + let NodeKind::Vector { network } = &n.kind else { panic!("not one vector: {:?}", n.kind) }; + assert_eq!(network.regions.len(), 1); + assert_eq!(network.regions[0].loops.len(), 2); + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); + let b = doc.world_bounds(n.id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 10.0, 100.0, 100.0)); +} + +#[test] +fn compound_runs_are_boolean_operands() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 60.0, 0.0, 20.0, 20.0), -1); + let c = with_op(rect("C", 10.0, 10.0, 10.0, 10.0), 1); + let doc = open(vec![shape_group("Mixed", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]); + let n = node(&doc, "Mixed"); + assert!(matches!(n.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }), "{:?}", n.kind); + let first = doc.node(n.children[0]).unwrap(); + let NodeKind::Vector { network } = &first.kind else { panic!("first operand isn't the compound path") }; + assert_eq!(network.regions[0].loops.len(), 2); +} + +#[test] +fn layer_winding_rule_counts() { + let mut p = shape_path("Wound", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["windingRule"] = json!(1); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Wound").kind else { panic!("not a vector") }; + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); +} + +/// Sketch fills an open path as if it were closed. +#[test] +fn filled_open_paths_are_closed() { + let open_path = shape_path("Lid", 0.0, 0.0, 40.0, 20.0, &[(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)], false); + let filled = styled(shape_group("Filled", 0.0, 0.0, 40.0, 20.0, vec![open_path.clone()]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let stroked = styled( + shape_group("Stroked", 0.0, 50.0, 40.0, 20.0, vec![open_path]), + "borders", + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 0, "thickness": 1}]), + ); + let doc = open(vec![filled, stroked]); + let NodeKind::Vector { network } = &node(&doc, "Filled").kind else { panic!("not a vector") }; + assert_eq!(network.regions.len(), 1); + assert_eq!(network.segments.len(), 3); + let NodeKind::Vector { network } = &node(&doc, "Stroked").kind else { panic!("not a vector") }; + assert!(network.regions.is_empty()); + assert_eq!(network.segments.len(), 2); +} + +#[test] +fn open_operands_of_booleans_are_closed() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(shape_path("Snout", 40.0, 10.0, 30.0, 20.0, &[(0.0, 0.0), (1.0, 0.5), (0.0, 1.0)], false), 0); + let doc = open(vec![styled(shape_group("Head", 0.0, 0.0, 70.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]))]); + let NodeKind::Vector { network } = &node(&doc, "Snout").kind else { panic!("not a vector") }; + assert_eq!(network.regions.len(), 1); +} diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs new file mode 100644 index 000000000..8574ab34c --- /dev/null +++ b/crates/format/src/sketch/proto.rs @@ -0,0 +1,119 @@ +//! Sketch prototype links, hotspots, export options and layout grids → REST. +//! +//! A layer's `flow` links it to an artboard (`destinationArtboardID`, or `back`) with an +//! `animationType` (0 none, 1–4 slide from the right, left, bottom, top); hotspots are layers that +//! only carry a link; `isFlowHome` marks an artboard where a prototype starts. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, bool_field, str_field}; + +/// Most export settings read from one layer. +const MAX_EXPORTS: usize = 64; + +/// The click interaction of layer `l`'s `flow`, if it has one. +pub(crate) fn interactions(l: &Value, node: &mut JsonMap) { + let Some(flow) = l.get("flow") else { return }; + let Some(dest) = str_field(flow, "destinationArtboardID").filter(|d| !d.is_empty()) else { return }; + let action = if dest == "back" { + json!({"type": "BACK"}) + } else { + let direction = match flow.get("animationType").and_then(Value::as_i64) { + Some(1) => Some("LEFT"), + Some(2) => Some("RIGHT"), + Some(3) => Some("TOP"), + Some(4) => Some("BOTTOM"), + _ => None, + }; + let transition = + direction.map_or(Value::Null, |d| json!({"type": "SLIDE_IN", "direction": d, "duration": 0.3, "easing": {"type": "EASE_IN_AND_OUT"}})); + json!({"type": "NODE", "destinationId": dest, "navigation": "NAVIGATE", "transition": transition}) + }; + node.insert("interactions".into(), json!([{"trigger": {"type": "ON_CLICK"}, "actions": [action]}])); +} + +/// The artboards of a page marked as where a prototype starts, as REST `flowStartingPoints`. +pub(crate) fn starting_points(page: &Value) -> Vec { + super::tree::layers(page) + .iter() + .filter(|a| bool_field(a, "isFlowHome") == Some(true)) + .filter_map(|a| Some(json!({"nodeId": str_field(a, "do_objectID")?, "name": str_field(a, "name").unwrap_or("Flow")}))) + .collect() +} + +/// Layer `l`'s export options as REST `exportSettings`. +pub(crate) fn export_settings(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let formats = l.get("exportOptions").and_then(|o| o.get("exportFormats")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut out = Vec::new(); + for f in formats.iter().take(MAX_EXPORTS) { + let format = match str_field(f, "fileFormat").unwrap_or("png").to_ascii_lowercase().as_str() { + "png" => "PNG", + "jpg" | "jpeg" => "JPG", + "svg" => "SVG", + "pdf" => "PDF", + "webp" => "WEBP", + _ => { + cx.warn("export settings in formats FigCraft doesn't export (TIFF, EPS, HEIC) were skipped"); + continue; + } + }; + let num = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let constraint = match (f.get("visibleScaleType").and_then(Value::as_i64), num("absoluteSize")) { + (Some(1), Some(s)) => json!({"type": "WIDTH", "value": s.min(16_384.0)}), + (Some(2), Some(s)) => json!({"type": "HEIGHT", "value": s.min(16_384.0)}), + _ => json!({"type": "SCALE", "value": num("scale").unwrap_or(1.0).min(64.0)}), + }; + let name: String = str_field(f, "name").unwrap_or("").chars().take(200).collect(); + if f.get("namingScheme").and_then(Value::as_i64) == Some(1) && !name.is_empty() { + cx.warn("export names Sketch puts before the layer name come after it"); + } + out.push(json!({"format": format, "suffix": name, "constraint": constraint})); + } + if !out.is_empty() { + node.insert("exportSettings".into(), Value::Array(out)); + } +} + +/// An artboard's layout grid (columns, rows) and square grid as REST `layoutGrids`, hidden: Sketch +/// shows them only while its layout (or grid) view is turned on. +pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) { + let mut out = Vec::new(); + if let Some(g) = l.get("layout").filter(|g| bool_field(g, "isEnabled") != Some(false)) { + let num = |k: &str| g.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0); + if bool_field(g, "drawVertical") != Some(false) + && let (Some(count), Some(width)) = (num("numberOfColumns"), num("columnWidth")) + { + out.push(json!({"pattern": "COLUMNS", "alignment": "MIN", "count": count.min(1000.0), "sectionSize": width.min(1e5), + "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": false})); + } + if bool_field(g, "drawHorizontal") == Some(true) { + let gutter = num("gutterHeight").unwrap_or(0.0).min(1e5); + let section = (gutter * num("rowHeightMultiplication").unwrap_or(1.0)).min(1e5); + if section > 0.0 { + let count = (h / (section + gutter)).floor().clamp(1.0, 1000.0); + out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": false})); + } + } + } + if let Some(g) = l.get("grid").filter(|g| bool_field(g, "isEnabled") != Some(false)) + && let Some(size) = g.get("gridSize").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) + { + out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": false})); + } + if !out.is_empty() { + node.insert("layoutGrids".into(), Value::Array(out)); + } +} + +/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout, +/// and overrides a master locks. +pub(crate) fn notes(cx: &mut Cx, l: &Value) { + // Stacks become auto layout; the older Smart Layout doesn't. + if l.get("groupLayout").and_then(|g| str_field(g, "_class")) == Some("MSImmutableInferredGroupLayout") { + cx.warn("Smart Layout was imported as laid out; it doesn't resize with its content"); + } + let locked = l.get("overrideProperties").and_then(Value::as_array).is_some_and(|p| p.iter().any(|o| bool_field(o, "canOverride") == Some(false))); + if locked { + cx.warn("overrides Sketch symbols locked can be changed in FigCraft"); + } +} diff --git a/crates/format/src/sketch/proto_tests.rs b/crates/format/src/sketch/proto_tests.rs new file mode 100644 index 000000000..facef17a8 --- /dev/null +++ b/crates/format/src/sketch/proto_tests.rs @@ -0,0 +1,127 @@ +//! Sketch prototype links, hotspots, export options, layout grids, smart layout and locked +//! overrides. + +use figcraft_doc::{Document, ExportFormat, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn screens(button: Value) -> Vec { + vec![artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![button]), artboard("A2", "Detail", 200.0, 0.0, 100.0, 100.0, vec![])] +} + +#[test] +fn flow_navigates() { + let b = with( + rect("Go", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 1}), + ); + let (doc, _) = open(screens(b)); + let go = node(&doc, "Go"); + assert_eq!(go.interactions.len(), 1, "{:?}", go.interactions); + let detail = node(&doc, "Detail").id; + let text = format!("{:?}", go.interactions[0]); + assert!(text.contains(&format!("{detail:?}")), "{text}"); +} + +#[test] +fn flow_back() { + let b = with( + rect("Back", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "back", "animationType": 0}), + ); + let (doc, _) = open(screens(b)); + let text = format!("{:?}", node(&doc, "Back").interactions); + assert!(text.contains("Back"), "{text}"); +} + +#[test] +fn hotspot_is_transparent_frame_with_interaction() { + let mut h = with( + rect("Hot", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 0}), + ); + h["_class"] = json!("MSImmutableHotspotLayer"); + let (doc, _) = open(screens(h)); + let n = node(&doc, "Hot"); + assert!(matches!(n.kind, NodeKind::Frame)); + assert!(n.fills.is_empty()); + assert_eq!(n.interactions.len(), 1); +} + +#[test] +fn flow_home_is_starting_point() { + let mut home = artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![]); + home["isFlowHome"] = json!(true); + let (doc, _) = open(vec![home]); + let page = doc.node(doc.pages[0]).unwrap(); + let NodeKind::Page { flows, .. } = &page.kind else { panic!("not a page") }; + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].start, node(&doc, "Home").id); +} + +#[test] +fn export_options() { + let mut r = rect("Icon", 0.0, 0.0, 20.0, 20.0); + r["exportOptions"] = json!({"_class": "exportOptions", "exportFormats": [ + {"_class": "exportFormat", "fileFormat": "png", "scale": 2, "name": "@2x", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0}, + {"_class": "exportFormat", "fileFormat": "svg", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0}, + {"_class": "exportFormat", "fileFormat": "png", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 64, "visibleScaleType": 1}, + ]}); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]); + let e = &node(&doc, "Icon").exports; + assert_eq!(e.len(), 3); + assert_eq!(e[0].format, ExportFormat::Png); + assert_eq!(e[0].suffix, "@2x"); + assert_eq!(e[1].format, ExportFormat::Svg); + let text = format!("{:?}", e[2]); + assert!(text.contains("64"), "{text}"); +} + +#[test] +fn layout_and_square_grids() { + let mut a = artboard("A", "Board", 0.0, 0.0, 1200.0, 800.0, vec![]); + a["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 84, "gutterWidth": 16, "horizontalOffset": 24, "numberOfColumns": 12, + "totalWidth": 1152, "guttersOutside": false, "drawVertical": true, "drawHorizontal": false, "gutterHeight": 8, "rowHeightMultiplication": 3}); + a["grid"] = json!({"_class": "simpleGrid", "isEnabled": true, "gridSize": 8, "thickGridTimes": 0}); + let (doc, _) = open(vec![a]); + let grids = format!("{:?}", node(&doc, "Board").grids); + assert_eq!(node(&doc, "Board").grids.len(), 2, "{grids}"); + assert!(grids.contains("12") && grids.contains("16"), "{grids}"); + // Hidden, as Sketch shows them only when its layout view is on. + assert!(node(&doc, "Board").grids.iter().all(|g| !g.visible), "{grids}"); +} + +#[test] +fn smart_layout_warns_once() { + let g = |id: &str| { + with( + group(id, 0.0, 0.0, 10.0, 10.0, vec![rect(&format!("{id}r"), 0.0, 0.0, 10.0, 10.0)]), + "groupLayout", + json!({"_class": "MSImmutableInferredGroupLayout", "axis": 0}), + ) + }; + let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![g("G1"), g("G2"), g("G3")])]); + assert_eq!(warnings.iter().filter(|w| w.contains("Smart Layout")).count(), 1, "{warnings:?}"); +} + +#[test] +fn locked_overrides_are_noted() { + let mut m = master("M", "S", "Locked", 50.0, 20.0, vec![text("L", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, [0.0, 0.0, 0.0, 1.0])])]); + m["overrideProperties"] = json!([{"_class": "MSImmutableOverrideProperty", "overrideName": "L_stringValue", "canOverride": false}]); + let (_, warnings) = open(vec![m]); + assert!(warnings.iter().any(|w| w.contains("locked")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/shared.rs b/crates/format/src/sketch/shared.rs new file mode 100644 index 000000000..ee1221486 --- /dev/null +++ b/crates/format/src/sketch/shared.rs @@ -0,0 +1,149 @@ +//! Sketch shared styles and swatches → REST style definitions and colour variables. +//! +//! A Sketch layer style holds fills, borders and effects together; FigCraft styles hold one kind +//! each, so a layer style becomes up to three styles named after it: its fills (a paint style), +//! its border colours (a paint style for strokes; widths stay on the layers) and its effects. A +//! text style becomes a text style; its colour stays on the layers, as FigCraft text styles carry +//! none. Layers link to them by `style.sharedObjectID`. Swatches (Color Variables, Sketch 69+) +//! become colour variables in one collection; colours name theirs by `swatchID`. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, color, str_field}; + +/// Most shared styles or swatches read from one file. +const MAX_SHARED: usize = 20_000; +/// The collection swatches become variables in. +const COLLECTION: &str = "sketch-color-variables"; +const MODE: &str = "default"; + +/// A file's shared styles and swatches. +#[derive(Default)] +pub(crate) struct Shared { + /// `(id, name, style)` of layer styles, then text styles. + layer: Vec<(String, String, Value)>, + text: Vec<(String, String, Value)>, + /// Positions in `layer` and `text` by id. + layer_index: BTreeMap, + text_index: BTreeMap, + swatches: BTreeMap, +} + +impl Shared { + pub(crate) fn read(document: &Value) -> Shared { + let mut s = Shared::default(); + let objects = + |key: &str| -> Vec { document.get(key).and_then(|c| c.get("objects")).and_then(Value::as_array).cloned().unwrap_or_default() }; + let foreign = |key: &str, inner: &str| -> Vec { + document.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|f| f.get(inner).cloned()).collect() + }; + let entry = |o: &Value| { + Some(( + str_field(o, "do_objectID")?.to_string(), + str_field(o, "name").unwrap_or("Style").chars().take(200).collect(), + o.get("value")?.clone(), + )) + }; + for o in objects("layerStyles").iter().chain(&foreign("foreignLayerStyles", "localSharedStyle")).take(MAX_SHARED) { + if let Some(e) = entry(o).filter(|e| !s.layer_index.contains_key(&e.0)) { + s.layer_index.insert(e.0.clone(), s.layer.len()); + s.layer.push(e); + } + } + for o in objects("layerTextStyles").iter().chain(&foreign("foreignTextStyles", "localSharedStyle")).take(MAX_SHARED) { + if let Some(e) = entry(o).filter(|e| !s.text_index.contains_key(&e.0)) { + s.text_index.insert(e.0.clone(), s.text.len()); + s.text.push(e); + } + } + let foreign_swatches = [foreign("foreignSwatches", "localSwatch"), foreign("foreignSwatches", "localSharedSwatch")].concat(); + for o in objects("sharedSwatches").iter().chain(&foreign_swatches).take(MAX_SHARED) { + if let Some((id, name, value)) = entry(o) + && let Some(c) = color(&value) + { + s.swatches.entry(id).or_insert((name, c)); + } + } + s + } + + /// The style of shared text (`text`) or layer style `id`. + pub(crate) fn style(&self, text: bool, id: &str) -> Option<&Value> { + let (index, list) = if text { (&self.text_index, &self.text) } else { (&self.layer_index, &self.layer) }; + list.get(*index.get(id)?).map(|e| &e.2) + } + + pub(crate) fn has_swatch(&self, id: &str) -> bool { + self.swatches.contains_key(id) + } + + /// The REST `styleDefinitions` of every shared style. + pub(crate) fn definitions(cx: &mut Cx) -> Vec { + let layer = cx.shared.layer.clone(); + let text = cx.shared.text.clone(); + let mut out = Vec::new(); + for (id, name, style) in &layer { + let fills = super::style::fills(cx, style, 100.0, 100.0); + if !fills.is_empty() { + out.push(json!({"id": format!("{id}:fill"), "name": name, "styleType": "FILL", "node": {"fills": fills}})); + } + let strokes = super::style::border_paints(cx, style, 100.0, 100.0); + if !strokes.is_empty() { + out.push(json!({"id": format!("{id}:stroke"), "name": format!("{name} border"), "styleType": "FILL", "node": {"fills": strokes}})); + } + let effects = super::style::effects(cx, style); + if !effects.is_empty() { + out.push(json!({"id": format!("{id}:effect"), "name": name, "styleType": "EFFECT", "node": {"effects": effects}})); + } + } + for (id, name, style) in &text { + let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) else { continue }; + let mut ts = super::text::type_style(cx, enc); + if let Some(m) = ts.as_object_mut() { + m.remove("fills"); + } + out.push(json!({"id": id, "name": name, "styleType": "TEXT", "node": {"type": "TEXT", "characters": "", "style": ts}})); + } + out + } + + /// Link `node` (layer `l`) to the styles made from its shared style. + pub(crate) fn link(&self, cx_defs: &BTreeSet, l: &Value, node: &mut JsonMap) { + let Some(sid) = l.get("style").and_then(|s| str_field(s, "sharedObjectID")) else { return }; + let mut refs = JsonMap::new(); + let is_text = str_field(l, "_class") == Some("text"); + if is_text && self.text_index.contains_key(sid) { + refs.insert("text".into(), json!(sid)); + } else if self.layer_index.contains_key(sid) { + for slot in ["fill", "stroke", "effect"] { + let id = format!("{sid}:{slot}"); + if cx_defs.contains(&id) { + refs.insert(slot.into(), json!(id)); + } + } + } + if !refs.is_empty() { + node.insert("styles".into(), Value::Object(refs)); + } + } + + /// The REST `variableCollections` and `variables` for the swatches (none without swatches). + pub(crate) fn variables(&self) -> Option<(Value, Value)> { + if self.swatches.is_empty() { + return None; + } + let ids: Vec<&String> = self.swatches.keys().collect(); + let collections = json!({COLLECTION: {"id": COLLECTION, "name": "Color Variables", "modes": [{"modeId": MODE, "name": "Default"}], + "defaultModeId": MODE, "variableIds": ids}}); + let vars: JsonMap = self + .swatches + .iter() + .map(|(id, (name, c))| { + (id.clone(), json!({"id": id, "name": name, "resolvedType": "COLOR", "variableCollectionId": COLLECTION, "valuesByMode": {MODE: c}})) + }) + .collect(); + Some((collections, Value::Object(vars))) + } +} diff --git a/crates/format/src/sketch/shared_tests.rs b/crates/format/src/sketch/shared_tests.rs new file mode 100644 index 000000000..571a6d2ea --- /dev/null +++ b/crates/format/src/sketch/shared_tests.rs @@ -0,0 +1,150 @@ +//! Sketch shared styles → FigCraft styles; Color Variables (swatches) → colour variables. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, Node, StyleKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; + +fn open(doc_json: Value, layers: Vec) -> (Document, Vec) { + let z = sketch_zip("83.2", 139, doc_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn style<'d>(doc: &'d Document, id: &str) -> &'d figcraft_doc::Style { + doc.styles.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("no style {id}: {:?}", doc.styles.iter().map(|s| &s.id).collect::>())) +} + +fn card_style() -> Value { + json!({"_class": "style", "fills": [solid_fill(0.0, 0.0, 1.0, 1.0)], "borders": [], + "shadows": [{"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.3)}], + "innerShadows": []}) +} + +fn with_shared(d: &mut Value, layer_styles: Vec, text_styles: Vec) { + d["layerStyles"]["objects"] = Value::Array(layer_styles); + d["layerTextStyles"]["objects"] = Value::Array(text_styles); +} + +fn shared(id: &str, name: &str, value: Value) -> Value { + json!({"_class": "sharedStyle", "do_objectID": id, "name": name, "value": value}) +} + +fn linked(mut l: Value, style: Value, shared_id: &str) -> Value { + l["style"] = style; + l["style"]["sharedObjectID"] = json!(shared_id); + l +} + +#[test] +fn layer_style_splits() { + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "CARD")]); + let n = node(&doc, "R"); + let fill = n.styles.fill.clone().expect("fill style linked"); + let effect = n.styles.effect.clone().expect("effect style linked"); + assert_eq!(style(&doc, &fill).name, "Card"); + assert!(matches!(style(&doc, &fill).kind, StyleKind::Paint { .. })); + assert!(matches!(style(&doc, &effect).kind, StyleKind::Effect { .. })); + assert_eq!(style(&doc, &effect).name, "Card"); +} + +#[test] +fn layer_style_borders_become_a_stroke_style() { + let mut st = card_style(); + st["borders"] = + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(1.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 2}]); + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", st.clone())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), st, "CARD")]); + let stroke = node(&doc, "R").styles.stroke.clone().expect("stroke style linked"); + assert!(style(&doc, &stroke).name.starts_with("Card")); +} + +#[test] +fn unused_shared_styles_are_listed() { + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![]); + assert!(doc.styles.iter().any(|s| s.name == "Card")); +} + +#[test] +fn text_style_links() { + let enc = json!({"MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": "Helvetica-Bold", "size": 32}}, + "MSAttributedStringColorAttribute": color(1.0, 0.0, 0.0, 1.0), + "paragraphStyle": {"_class": "paragraphStyle", "alignment": 0, "maximumLineHeight": 40, "minimumLineHeight": 40}}); + let st = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": enc, "verticalAlignment": 0}}); + let mut d = document(&["P"]); + with_shared(&mut d, vec![], vec![shared("H1", "Heading/H1", st.clone())]); + let t = linked(text("Title", 0.0, 0.0, 200.0, 40.0, "Hello", &[(0, 5, "Helvetica-Bold", 32.0, [1.0, 0.0, 0.0, 1.0])]), st, "H1"); + let (doc, _) = open(d, vec![t]); + let n = node(&doc, "Title"); + let sid = n.styles.text.clone().expect("text style linked"); + let s = style(&doc, &sid); + assert_eq!(s.name, "Heading/H1"); + let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") }; + assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0)); + assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))); +} + +#[test] +fn locally_changed_style_keeps_link() { + let mut local = card_style(); + local["fills"] = json!([solid_fill(0.0, 1.0, 0.0, 1.0)]); + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), local, "CARD")]); + let n = node(&doc, "R"); + assert!(n.styles.fill.is_some()); + assert!(matches!(n.fills[0].kind, PaintKind::Solid { color } if color.g == 1.0 && color.b == 0.0)); +} + +#[test] +fn foreign_styles_too() { + let mut d = document(&["P"]); + d["foreignLayerStyles"] = json!([{"_class": "MSImmutableForeignLayerStyle", "sourceLibraryName": "Kit", "localSharedStyle": shared("FCARD", "Kit card", card_style())}]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "FCARD")]); + assert_eq!(style(&doc, node(&doc, "R").styles.fill.as_deref().unwrap()).name, "Kit card"); +} + +#[test] +fn swatches_are_color_variables() { + let mut d = document(&["P"]); + d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW1", "name": "Brand/Blue", "value": color(0.06, 0.38, 1.0, 1.0)}]}); + d["foreignSwatches"] = json!([{"_class": "MSImmutableForeignSwatch", "sourceLibraryName": "Kit", "localSwatch": {"_class": "swatch", "do_objectID": "SW2", "name": "Kit/Red", "value": color(1.0, 0.0, 0.0, 1.0)}}]); + let mut fill = solid_fill(0.06, 0.38, 1.0, 1.0); + fill["color"]["swatchID"] = json!("SW1"); + let mut fill2 = solid_fill(1.0, 0.0, 0.0, 1.0); + fill2["color"]["swatchID"] = json!("SW2"); + let (doc, _) = open( + d, + vec![styled(rect("R", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill])), styled(rect("S", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill2]))], + ); + let coll = doc.variables.collections.iter().find(|c| c.name == "Color Variables").expect("collection"); + let var = doc.variables.variables.iter().find(|v| v.name == "Brand/Blue").expect("variable"); + assert_eq!(var.collection, coll.id); + assert!(doc.variables.variables.iter().any(|v| v.name == "Kit/Red")); + assert_eq!(node(&doc, "R").bound.get("fill"), Some(&var.id)); + assert!(node(&doc, "S").bound.contains_key("fill")); +} + +#[test] +fn unknown_shared_ids_are_ignored() { + let (doc, _) = open(document(&["P"]), vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "NOPE")]); + let n = node(&doc, "R"); + assert!(n.styles.fill.is_none()); + let mut f = solid_fill(0.0, 0.0, 0.0, 1.0); + f["color"]["swatchID"] = json!("NOPE"); + let (doc, _) = + open(document(&["P"]), vec![styled(text("T", 0.0, 0.0, 10.0, 10.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]), "fills", json!([f]))]); + assert!(node(&doc, "T").bound.is_empty()); +} diff --git a/crates/format/src/sketch/stack.rs b/crates/format/src/sketch/stack.rs new file mode 100644 index 000000000..89f67ca86 --- /dev/null +++ b/crates/format/src/sketch/stack.rs @@ -0,0 +1,104 @@ +//! Sketch 2025+ stacks (`groupLayout` `MSImmutableFlexGroupLayout`) → REST auto layout. +//! +//! The enums follow the order Sketch's plugin API documents them in (checked against files): +//! direction row, column; justify start, center, end, between, around, evenly; align items start, +//! center, end, stretch, none; stacking order first on top, last on top. Sizing (`horizontalSizing`, +//! `verticalSizing`) is fixed, fit (hug), fill, relative. Sketch lists a stack's first item last +//! (on top in the layer list), so the flow order is the reverse of its `layers`. + +use serde_json::{Map as JsonMap, Value, json}; + +/// Whether `l` is a stack. +pub(crate) fn is_stack(l: &Value) -> bool { + l.get("groupLayout").and_then(|g| g.get("_class")).and_then(Value::as_str) == Some("MSImmutableFlexGroupLayout") +} + +/// Make `node` (the REST node of frame `l`, its children already built in Sketch's order) an auto +/// layout frame when `l` is a stack. +pub(crate) fn apply(l: &Value, node: &mut JsonMap) { + if !is_stack(l) || !matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let Some(g) = l.get("groupLayout") else { return }; + let int = |k: &str| g.get(k).and_then(Value::as_i64).unwrap_or(0); + let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(0.0).clamp(0.0, 1e5); + let row = int("flexDirection") == 0; + node.insert("layoutMode".into(), json!(if row { "HORIZONTAL" } else { "VERTICAL" })); + node.insert("itemSpacing".into(), json!(num(g, "allGuttersGap"))); + node.insert( + "primaryAxisAlignItems".into(), + json!(match int("justifyContent") { + 1 => "CENTER", + 2 => "MAX", + 3 => "SPACE_BETWEEN", + 4 => "SPACE_AROUND", + 5 => "SPACE_EVENLY", + _ => "MIN", + }), + ); + let stretch = int("alignItems") == 3; + node.insert( + "counterAxisAlignItems".into(), + json!(match int("alignItems") { + 1 => "CENTER", + 2 => "MAX", + _ => "MIN", + }), + ); + if g.get("wrappingEnabled").and_then(Value::as_bool) == Some(true) { + node.insert("layoutWrap".into(), json!("WRAP")); + node.insert("counterAxisSpacing".into(), json!(num(g, "crossAxisGutterGap"))); + } + for (k, rest) in + [("leftPadding", "paddingLeft"), ("topPadding", "paddingTop"), ("rightPadding", "paddingRight"), ("bottomPadding", "paddingBottom")] + { + node.insert(rest.into(), json!(num(l, k))); + } + node.insert("itemReverseZIndex".into(), json!(int("stackingOrder") == 0)); + node.insert("strokesIncludedInLayout".into(), json!(g.get("bordersAffectLayout").and_then(Value::as_bool) == Some(true))); + // The stack's own sizing: fit hugs its content. + let (main, cross) = if row { ("horizontalSizing", "verticalSizing") } else { ("verticalSizing", "horizontalSizing") }; + let mode = |k: &str| if l.get(k).and_then(Value::as_i64) == Some(1) { "AUTO" } else { "FIXED" }; + node.insert("primaryAxisSizingMode".into(), json!(mode(main))); + node.insert("counterAxisSizingMode".into(), json!(mode(cross))); + // Items: flow order, sizing, absolute ones. + // Source layers by id (the first of a repeated id). + let mut sources: std::collections::BTreeMap<&str, &Value> = std::collections::BTreeMap::new(); + for src in super::tree::layers(l) { + if let Some(id) = src.get("do_objectID").and_then(Value::as_str) { + sources.entry(id).or_insert(src); + } + } + if let Some(kids) = node.get_mut("children").and_then(Value::as_array_mut) { + // Children were built one per source layer, skipped ones aside: match them by id. + for kid in kids.iter_mut() { + let Some(src) = kid.get("id").and_then(Value::as_str).and_then(|id| sources.get(id).copied()) else { continue }; + let Some(m) = kid.as_object_mut() else { continue }; + item(src, m, row, stretch); + } + kids.reverse(); + } +} + +/// An item's place in its stack: absolute, or sized fixed, hugging or filling. +fn item(src: &Value, m: &mut JsonMap, row: bool, stretch: bool) { + if src.get("flexItem").and_then(|f| f.get("ignoreLayout")).and_then(Value::as_bool) == Some(true) { + m.insert("layoutPositioning".into(), json!("ABSOLUTE")); + return; + } + let sizing = |k: &str| match src.get(k).and_then(Value::as_i64) { + Some(1) => "HUG", + Some(2) => "FILL", + _ => "FIXED", + }; + let (h, v) = (sizing("horizontalSizing"), sizing("verticalSizing")); + m.insert("layoutSizingHorizontal".into(), json!(h)); + m.insert("layoutSizingVertical".into(), json!(v)); + let (main, cross) = if row { (h, v) } else { (v, h) }; + if main == "FILL" { + m.insert("layoutGrow".into(), json!(1)); + } + if cross == "FILL" || stretch { + m.insert("layoutAlign".into(), json!("STRETCH")); + } +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs new file mode 100644 index 000000000..5e4768036 --- /dev/null +++ b/crates/format/src/sketch/style.rs @@ -0,0 +1,434 @@ +//! Sketch styles → REST paints, strokes, effects, opacity and blend modes. +//! +//! A layer's `style` lists `fills` and `borders` (each a colour, gradient or, for fills, an image +//! pattern, with its own `contextSettings` opacity and blend mode), `borderOptions` (dashes, caps, +//! joins), `shadows`, `innerShadows`, one `blur` and the layer's own `contextSettings`. Every +//! border has its own thickness and position; FigCraft strokes share one, so the first enabled +//! border's is used. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::path::parse_point; +use super::tree::{Cx, color}; + +/// Most paints or effects read from one list. +const MAX_LIST: usize = 64; + +/// Write the paints, strokes, effects, opacity and blend mode of `style` into `node`, a layer of +/// `w`×`h`. Lists the style doesn't have are left as they are. +pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { + let fills = fills(cx, style, w, h); + if !fills.is_empty() { + node.insert("fills".into(), Value::Array(fills)); + } + borders(cx, style, w, h, node); + let effects = effects(cx, style); + if !effects.is_empty() { + node.insert("effects".into(), Value::Array(effects)); + } + if let Some(ctx) = style.get("contextSettings") { + if let Some(o) = num(ctx, "opacity") { + node.insert("opacity".into(), json!(o.clamp(0.0, 1.0))); + } + if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).filter(|b| *b != 0).and_then(blend) { + node.insert("blendMode".into(), json!(b)); + } + } +} + +/// A style's fills as REST paints. +pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + // Tints (`layeringType` 1) colour the layers inside; they aren't paints of the layer. + list(style, "fills").iter().filter(|f| f.get("layeringType").and_then(Value::as_i64) != Some(1)).filter_map(|f| paint(cx, f, w, h)).collect() +} + +/// A style's borders as REST paints. +pub(crate) fn border_paints(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + list(style, "borders").iter().filter_map(|b| paint(cx, b, w, h)).collect() +} + +/// Remove what [`apply`] writes, so another style can take its place. +pub(crate) fn clear(node: &mut JsonMap) { + for key in [ + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "strokeJoin", + "effects", + "opacity", + "blendMode", + ] { + node.remove(key); + } +} + +/// One fill or border as a REST paint; `None` for kinds FigCraft doesn't have (with a warning). +fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { + let mut p = match f.get("fillType").and_then(Value::as_i64).unwrap_or(0) { + 0 => json!({"type": "SOLID", "color": f.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}))}), + 1 => gradient(f.get("gradient")?, w, h)?, + 4 => { + let Some(r) = f.get("image").and_then(|i| i.get("_ref")).and_then(Value::as_str) else { + cx.warn("image fills stored inside the JSON were skipped"); + return None; + }; + let mut p = json!({"type": "IMAGE", "imageRef": r}); + match f.get("patternFillType").and_then(Value::as_i64).unwrap_or(1) { + 0 => { + p["scaleMode"] = json!("TILE"); + p["scalingFactor"] = json!(num(f, "patternTileScale").filter(|s| *s > 0.0).unwrap_or(1.0)); + } + 2 => { + p["scaleMode"] = json!("STRETCH"); + p["imageTransform"] = json!([[1, 0, 0], [0, 1, 0]]); + } + 3 => p["scaleMode"] = json!("FIT"), + _ => p["scaleMode"] = json!("FILL"), + } + p + } + 5 => { + cx.warn("noise fills were skipped"); + return None; + } + other => { + cx.warn(format!("fills of type {other} were skipped")); + return None; + } + }; + p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); + // A colour variable binds a plain colour (a gradient's or image's `color` is unused). + if f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0 + && let Some(var) = swatch(cx, f.get("color")) + { + p["boundVariables"] = var; + } + if let Some(ctx) = f.get("contextSettings") { + if let Some(o) = num(ctx, "opacity") { + p["opacity"] = json!(o.clamp(0.0, 1.0)); + } + if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).and_then(blend) { + p["blendMode"] = json!(b); + } + } + Some(p) +} + +/// A gradient fill: `from` and `to` in 0–1 of the layer; a radial gradient's `elipseLength` is its +/// width across the main axis as a share of the main axis, measured in pixels. +fn gradient(g: &Value, w: f64, h: f64) -> Option { + let point = |k: &str| g.get(k).and_then(Value::as_str).and_then(parse_point); + let (from, to) = (point("from").unwrap_or((0.5, 0.0)), point("to").unwrap_or((0.5, 1.0))); + let stops: Vec = list(g, "stops") + .iter() + .filter_map(|s| Some(json!({"position": num(s, "position")?.clamp(0.0, 1.0), "color": s.get("color").and_then(color)?}))) + .collect(); + let (ty, handles) = match g.get("gradientType").and_then(Value::as_i64).unwrap_or(0) { + 1 => { + let e = num(g, "elipseLength").filter(|e| *e > 0.0).unwrap_or(1.0); + let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h); + let across = if w > 0.0 && h > 0.0 { (from.0 - dy * e / w, from.1 + dx * e / h) } else { from }; + ("GRADIENT_RADIAL", json!([pt(from), pt(to), pt(across)])) + } + // Sketch starts on the right and turns clockwise; from/to don't change it. + 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((1.0, 0.5)), pt((0.5, 1.0))])), + // Square to the line in pixels, as Sketch draws it (FigCraft's default is square in 0–1). + _ => { + let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h); + let across = if w > 0.0 && h > 0.0 { (from.0 - dy / w, from.1 + dx / h) } else { (from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)) }; + ("GRADIENT_LINEAR", json!([pt(from), pt(to), pt(across)])) + } + }; + Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops})) +} + +fn pt((x, y): (f64, f64)) -> Value { + json!({"x": x, "y": y}) +} + +/// Borders → stroke paints, and the first enabled border's thickness and position. +fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { + let list = list(style, "borders"); + let strokes = border_paints(cx, style, w, h); + if strokes.is_empty() { + return; + } + let enabled: Vec<&Value> = list.iter().filter(|b| b.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)).collect(); + let first = enabled.first().copied().or(list.first()); + let shape = |b: &Value| (num(b, "thickness").unwrap_or(1.0), b.get("position").and_then(Value::as_i64).unwrap_or(0)); + if let Some(b) = first { + let (t, pos) = shape(b); + if enabled.iter().any(|o| shape(o) != (t, pos)) { + cx.warn("layers with several borders of different widths or positions use the first one's"); + } + node.insert("strokeWeight".into(), json!(t.clamp(0.0, 1e6))); + node.insert( + "strokeAlign".into(), + json!(match pos { + 1 => "INSIDE", + 2 => "OUTSIDE", + _ => "CENTER", + }), + ); + } + node.insert("strokes".into(), Value::Array(strokes)); + // Per-side thickness (Sketch 2025+): a side is as thick as the border less its inset. + let first = list.iter().find(|b| b.get("isEnabled").and_then(Value::as_bool) != Some(false)); + if let Some(b) = first + && let Some(sides) = b.get("sides") + { + let t = num(b, "thickness").unwrap_or(1.0).clamp(0.0, 1e6); + let side = |k: &str| (t - num(sides, k).unwrap_or(0.0)).clamp(0.0, 1e6); + node.insert( + "individualStrokeWeights".into(), + json!({"top": side("topInset"), "right": side("rightInset"), "bottom": side("bottomInset"), "left": side("leftInset")}), + ); + } + if let Some(o) = style.get("borderOptions") { + let dashes: Vec = o + .get("dashPattern") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|d| d.is_finite() && *d >= 0.0) + .take(MAX_LIST) + .collect(); + if !dashes.is_empty() { + node.insert("strokeDashes".into(), json!(dashes)); + } + node.insert( + "strokeCap".into(), + json!(match o.get("lineCapStyle").and_then(Value::as_i64) { + Some(1) => "ROUND", + Some(2) => "SQUARE", + _ => "NONE", + }), + ); + node.insert( + "strokeJoin".into(), + json!(match o.get("lineJoinStyle").and_then(Value::as_i64) { + Some(1) => "ROUND", + Some(2) => "BEVEL", + _ => "MITER", + }), + ); + } + for (key, field) in [("startMarkerType", "strokeCapStart"), ("endMarkerType", "strokeCapEnd")] { + let cap = match style.get(key).and_then(Value::as_i64).unwrap_or(0) { + 0 => continue, + 1 => "LINE_ARROW", + 2 => "TRIANGLE_ARROW", + 5 => "CIRCLE_FILLED", + _ => { + cx.warn("line ends other than arrows and filled circles were drawn without an end"); + continue; + } + }; + node.insert(field.into(), json!(cap)); + } +} + +/// Shadows, inner shadows and the blur as REST effects. +pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { + let mut out = Vec::new(); + for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { + for s in list(style, key) { + // Sketch 2025 and newer keep inner shadows in the one list, marked. + let ty = if s.get("isInnerShadow").and_then(Value::as_bool) == Some(true) { "INNER_SHADOW" } else { ty }; + let mut c = s.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5})); + let o = s.get("contextSettings").and_then(|c| num(c, "opacity")).unwrap_or(1.0).clamp(0.0, 1.0); + c["a"] = json!(c["a"].as_f64().unwrap_or(1.0) * o); + out.push(json!({ + "type": ty, + "visible": s.get("isEnabled").and_then(Value::as_bool).unwrap_or(true), + "radius": num(s, "blurRadius").unwrap_or(0.0).clamp(0.0, 1e6), + "spread": num(s, "spread").unwrap_or(0.0), + "offset": {"x": num(s, "offsetX").unwrap_or(0.0), "y": num(s, "offsetY").unwrap_or(0.0)}, + "color": c, + })); + } + } + // One `blur` (older files) or a list of `blurs` (Sketch 2025 and newer). + let one = style.get("blur").into_iter(); + let many = style.get("blurs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().take(MAX_LIST); + for b in one.chain(many).filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { + let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6); + match b.get("type").and_then(Value::as_i64).unwrap_or(0) { + 0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})), + 3 => out.push(background_blur(b, radius)), + 4 => out.push(json!({ + "type": "GLASS", + "visible": true, + "radius": radius, + "refraction": num(b, "distortion").unwrap_or(0.5).clamp(0.0, 1.0), + "depth": num(b, "depth").unwrap_or(0.5).clamp(0.0, 1.0), + })), + _ => { + // Motion and zoom blurs: a plain blur of half the radius softens about as much. + cx.warn("motion and zoom blurs were drawn as plain blurs"); + out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius / 2.0})); + } + } + } + out.truncate(MAX_LIST); + out +} + +/// A background blur; a progressive one (`isProgressive`) ramps along its gradient, whose stops' +/// opacity scales the radius at each end. +fn background_blur(b: &Value, radius: f64) -> Value { + let g = b.get("gradient").filter(|_| b.get("isProgressive").and_then(Value::as_bool) == Some(true)); + let Some(g) = g else { return json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius}) }; + let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let at = |s: Option<&Value>| s.and_then(|s| s.get("color")).and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0) * radius; + let end = |k: &str, d: (f64, f64)| { + let (x, y) = g.get(k).and_then(Value::as_str).and_then(super::path::parse_point).unwrap_or(d); + json!({"x": x.clamp(-1e3, 1e3), "y": y.clamp(-1e3, 1e3)}) + }; + json!({ + "type": "BACKGROUND_BLUR", + "visible": true, + "blurType": "PROGRESSIVE", + "startRadius": at(stops.first()), + "radius": at(stops.last()), + "startOffset": end("from", (0.5, 0.0)), + "endOffset": end("to", (0.5, 1.0)), + }) +} + +/// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral, +/// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note). +pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option { + let c = style?.get("colorControls").filter(|c| c.get("isEnabled").and_then(Value::as_bool) == Some(true))?; + let get = |k: &str, neutral: f64| num(c, k).unwrap_or(neutral); + if get("hue", 0.0).abs() > 1e-6 { + cx.warn("hue changes of bitmaps were skipped"); + } + Some(json!({ + "saturation": (get("saturation", 1.0) - 1.0).clamp(-1.0, 1.0), + "contrast": (get("contrast", 1.0) - 1.0).clamp(-1.0, 1.0), + "exposure": get("brightness", 0.0).clamp(-1.0, 1.0), + })) +} + +/// The variable binding of a colour that names a swatch the file has (`{color: alias}`). +pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { + let id = c?.get("swatchID").and_then(Value::as_str).filter(|id| cx.shared.has_swatch(id))?; + Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}})) +} + +/// The tint a style gives the layers inside: its first enabled plain fill with `layeringType` 1 +/// (Sketch 2025 and newer), or with no `layeringType` when `legacy` (older files: a group's or +/// instance's fill was its tint). Sketch draws a tint as the colour of everything inside. +pub(crate) fn tint_of(style: Option<&Value>, legacy: bool) -> Option { + let fills = style?.get("fills")?.as_array()?; + let f = fills.iter().filter(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false)).find(|f| { + match f.get("layeringType").and_then(Value::as_i64) { + Some(t) => t == 1, + None => legacy, + } + })?; + (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").filter(|c| c.is_object()).cloned()).flatten() +} + +/// Recolour `layers` (Sketch JSON) with tint `color`: every enabled fill and border takes it, +/// text takes it, nested instances take it as their own tint. Opacity stays as it was. +pub(crate) fn tint_layers(layers: &mut [Value], color: &Value, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + if let Some(st) = l.get_mut("style").and_then(Value::as_object_mut) { + for key in ["fills", "borders"] { + for p in st.get_mut(key).and_then(Value::as_array_mut).into_iter().flatten() { + if let Some(m) = p.as_object_mut() + && m.get("isEnabled").and_then(Value::as_bool) != Some(false) + && !matches!(m.get("fillType").and_then(Value::as_i64), Some(4)) + { + let c = tinted(m.get("color"), color); + m.insert("fillType".into(), json!(0)); + m.insert("color".into(), c); + } + } + } + if let Some(enc) = st.get_mut("textStyle").and_then(|t| t.get_mut("encodedAttributes")).and_then(Value::as_object_mut) { + let c = tinted(enc.get("MSAttributedStringColorAttribute"), color); + enc.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) { + for r in runs.iter_mut().filter_map(|r| r.get_mut("attributes")).filter_map(Value::as_object_mut) { + let c = tinted(r.get("MSAttributedStringColorAttribute"), color); + r.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if l.get("_class").and_then(Value::as_str) == Some("symbolInstance") { + set_tint(l, color); + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + tint_layers(kids, color, depth + 1); + } + } +} + +/// Colour `old` (Sketch JSON) tinted with `tint`: the tint's colour, with both opacities. +fn tinted(old: Option<&Value>, tint: &Value) -> Value { + let alpha = |c: Option<&Value>| c.and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0); + let mut c = tint.clone(); + if let Some(m) = c.as_object_mut() { + m.insert("alpha".into(), json!(alpha(Some(tint)) * alpha(old))); + } + c +} + +/// Give layer `l` (Sketch JSON) tint `color`: as its one fill. +pub(crate) fn set_tint(l: &mut Value, color: &Value) { + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color}); + if let Some(m) = l.as_object_mut() { + let st = m.entry("style").or_insert_with(|| json!({"_class": "style"})); + if let Some(st) = st.as_object_mut() { + st.insert("fills".into(), json!([fill])); + } + } +} + +/// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. +fn blend(mode: i64) -> Option<&'static str> { + const MODES: [&str; 18] = [ + "NORMAL", + "DARKEN", + "MULTIPLY", + "COLOR_BURN", + "LIGHTEN", + "SCREEN", + "COLOR_DODGE", + "OVERLAY", + "SOFT_LIGHT", + "HARD_LIGHT", + "DIFFERENCE", + "EXCLUSION", + "HUE", + "SATURATION", + "COLOR", + "LUMINOSITY", + "LINEAR_BURN", + "LINEAR_DODGE", + ]; + MODES.get(usize::try_from(mode).ok()?).copied() +} + +fn list<'v>(v: &'v Value, key: &str) -> &'v [Value] { + let l = v.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + l.get(..MAX_LIST).unwrap_or(l) +} + +fn num(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()) +} diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs new file mode 100644 index 000000000..e553ab6ba --- /dev/null +++ b/crates/format/src/sketch/style_tests.rs @@ -0,0 +1,366 @@ +//! Sketch styles → paints, strokes, effects, opacity, blend modes and masks. + +use figcraft_doc::color::{BlendMode, ColorProfile, GradientKind, ImageScaleMode, PaintKind}; +use figcraft_doc::{Document, EffectKind, NodeKind, StrokeAlign, StrokeCap, StrokeJoin}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open_doc(document_json: Value, layers: Vec, files: &[(&str, &[u8])]) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], files); + import(&z, "f").unwrap() +} + +fn open(layers: Vec) -> (Document, Vec) { + open_doc(document(&["P"]), layers, &[]) +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn r(name: &str) -> Value { + rect(name, 0.0, 0.0, 100.0, 50.0) +} + +#[test] +fn solid_and_disabled_fills() { + let mut second = solid_fill(0.0, 0.0, 1.0, 1.0); + second["isEnabled"] = json!(false); + let mut half = solid_fill(1.0, 0.0, 0.0, 1.0); + half["contextSettings"]["opacity"] = json!(0.5); + let (doc, _) = open(vec![styled(r("Two"), "fills", json!([half, second]))]); + let fills = &node(&doc, "Two").fills; + assert_eq!(fills.len(), 2); + assert!(matches!(fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.b == 0.0)); + assert!((fills[0].opacity - 0.5).abs() < 1e-6 && fills[0].visible); + assert!(!fills[1].visible); +} + +#[test] +fn collapsed_shape_group_takes_the_groups_style() { + let inner = with(r("Inner"), "booleanOperation", json!(-1)); + let g = styled(shape_group("Button", 0.0, 0.0, 100.0, 50.0, vec![inner]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)])); + let (doc, _) = open(vec![g]); + assert!(matches!(node(&doc, "Button").fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0)); +} + +#[test] +fn boolean_shape_group_takes_the_groups_style() { + let a = with(rect("A", 0.0, 0.0, 50.0, 50.0), "booleanOperation", json!(-1)); + let b = with(rect("B", 10.0, 10.0, 20.0, 20.0), "booleanOperation", json!(1)); + let g = styled(shape_group("Ring", 0.0, 0.0, 50.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 1.0, 0.0, 1.0)])); + let (doc, _) = open(vec![g]); + assert_eq!(node(&doc, "Ring").fills.len(), 1); +} + +#[test] +fn gradients() { + let stops = json!([{"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)}, + {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]); + let grad = |ty: i64, ellipse: f64| { + json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": ty, "from": "{0.5, 0}", "to": "{0.5, 1}", "elipseLength": ellipse, "stops": stops.clone()}}) + }; + let (doc, _) = open(vec![ + styled(r("Linear"), "fills", json!([grad(0, 0.0)])), + styled(r("Radial"), "fills", json!([grad(1, 0.5)])), + styled(r("Angular"), "fills", json!([grad(2, 0.0)])), + ]); + let g = |name: &str| match &node(&doc, name).fills[0].kind { + PaintKind::Gradient(g) => g.clone(), + other => panic!("{name}: {other:?}"), + }; + let lin = g("Linear"); + assert_eq!(lin.kind, GradientKind::Linear); + assert_eq!((lin.from, lin.to), ((0.5, 0.0), (0.5, 1.0))); + assert_eq!(lin.stops.len(), 2); + assert_eq!(lin.stops[1].color.b, 1.0); + assert_eq!(g("Radial").kind, GradientKind::Radial); + // Sketch starts an angular gradient on the right and turns clockwise, whatever its from/to. + let ang = g("Angular"); + assert_eq!(ang.kind, GradientKind::Angular); + assert_eq!((ang.from, ang.to, ang.width), ((0.5, 0.5), (1.0, 0.5), (0.5, 1.0))); + // The radial gradient's width handle: half the length, across the main axis (in pixels). + let rad = g("Radial"); + let (dx, dy) = ((rad.width.0 - rad.from.0) * 100.0, (rad.width.1 - rad.from.1) * 50.0); + assert!(((dx * dx + dy * dy).sqrt() - 25.0).abs() < 1e-6, "{rad:?}"); +} + +#[test] +fn pattern_fill_modes() { + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let pat = |mode: i64| { + json!({"_class": "fill", "isEnabled": true, "fillType": 4, "patternFillType": mode, "patternTileScale": 1, + "image": {"_class": "MSJSONFileReference", "_ref": "images/p1.png"}}) + }; + let layers = (0..4).map(|m| styled(r(&format!("M{m}")), "fills", json!([pat(m)]))).collect(); + let (doc, _) = open_doc(document(&["P"]), layers, &[("images/p1.png", &png)]); + let mode = |name: &str| match &node(&doc, name).fills[0].kind { + PaintKind::Image(i) => i.scale_mode, + other => panic!("{other:?}"), + }; + assert_eq!(mode("M0"), ImageScaleMode::Tile); + assert_eq!(mode("M1"), ImageScaleMode::Fill); + assert_eq!(mode("M2"), ImageScaleMode::Crop); + assert_eq!(mode("M3"), ImageScaleMode::Fit); + assert_eq!(doc.images.len(), 1); +} + +#[test] +fn noise_fill_warns() { + let noise = json!({"_class": "fill", "isEnabled": true, "fillType": 5, "noiseIntensity": 0.3}); + let (_, warnings) = open(vec![styled(r("Noisy"), "fills", json!([noise]))]); + assert!(warnings.iter().any(|w| w.contains("noise")), "{warnings:?}"); +} + +#[test] +fn borders() { + let border = |pos: i64, t: f64| json!({"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": pos, "thickness": t}); + let opts = json!({"_class": "borderOptions", "isEnabled": true, "dashPattern": [4, 2], "lineCapStyle": 1, "lineJoinStyle": 2}); + let (doc, warnings) = open(vec![ + styled(r("Center"), "borders", json!([border(0, 2.0)])), + styled(styled(r("Inside"), "borders", json!([border(1, 3.0)])), "borderOptions", opts), + styled(r("Outside"), "borders", json!([border(2, 1.0)])), + styled(r("Two"), "borders", json!([border(1, 1.0), border(1, 5.0)])), + ]); + let s = |n: &str| node(&doc, n).stroke.clone(); + assert_eq!((s("Center").align, s("Center").weight), (StrokeAlign::Center, 2.0)); + assert_eq!((s("Inside").align, s("Inside").weight), (StrokeAlign::Inside, 3.0)); + assert_eq!(s("Inside").dashes, vec![4.0, 2.0]); + assert_eq!((s("Inside").cap_start, s("Inside").join), (StrokeCap::Round, StrokeJoin::Bevel)); + assert_eq!(s("Outside").align, StrokeAlign::Outside); + assert_eq!(node(&doc, "Two").strokes.len(), 2); + assert_eq!(s("Two").weight, 1.0); + assert!(warnings.iter().any(|w| w.contains("borders")), "{warnings:?}"); +} + +#[test] +fn shadows_and_blurs() { + let shadow = json!({"_class": "shadow", "isEnabled": true, "blurRadius": 8, "offsetX": 2, "offsetY": 4, "spread": 1, "color": color(0.0, 0.0, 0.0, 0.5), + "contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}}); + let mut off = shadow.clone(); + off["isEnabled"] = json!(false); + let blur = |ty: i64| json!({"_class": "blur", "isEnabled": true, "radius": 6, "type": ty}); + let (doc, warnings) = open(vec![ + styled(styled(r("Shadowed"), "shadows", json!([shadow, off])), "innerShadows", json!([shadow])), + styled(r("Blurred"), "blur", blur(0)), + styled(r("Frosted"), "blur", blur(3)), + styled(r("Moving"), "blur", blur(1)), + ]); + let fx = &node(&doc, "Shadowed").effects; + assert_eq!(fx.len(), 3); + match &fx[0].kind { + EffectKind::DropShadow(s) => assert_eq!((s.offset, s.radius, s.spread, s.color.a), ((2.0, 4.0), 8.0, 1.0, 0.5)), + other => panic!("{other:?}"), + } + assert!(!fx[1].visible); + assert!(matches!(fx[2].kind, EffectKind::InnerShadow(_))); + assert!(matches!(node(&doc, "Blurred").effects[0].kind, EffectKind::LayerBlur(_))); + assert!(matches!(node(&doc, "Frosted").effects[0].kind, EffectKind::BackgroundBlur(_))); + // Motion blur: drawn as a plain blur of half the radius. + assert!(matches!(node(&doc, "Moving").effects[0].kind, EffectKind::LayerBlur(_))); + assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); +} + +#[test] +fn opacity_and_blend() { + let modes = [ + BlendMode::Normal, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + BlendMode::PlusDarker, + BlendMode::PlusLighter, + ]; + let layers = (0..modes.len()) + .map(|i| styled(r(&format!("B{i}")), "contextSettings", json!({"_class": "graphicsContextSettings", "opacity": 0.3, "blendMode": i}))) + .collect(); + let (doc, _) = open(layers); + for (i, m) in modes.iter().enumerate() { + let n = node(&doc, &format!("B{i}")); + assert!((n.opacity - 0.3).abs() < 1e-6); + // A normal layer passes through like any other. + if i > 0 { + assert_eq!(n.blend, *m, "mode {i}"); + } + } +} + +#[test] +fn clipping_mask_chain() { + let mask = with(r("Mask"), "hasClippingMask", json!(true)); + let a = r("A"); + let b = with(r("B"), "shouldBreakMaskChain", json!(true)); + let c = r("C"); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![mask, a, b, c])]); + let m = node(&doc, "Mask"); + assert!(m.is_mask); + // The mask and what it clips share a group of their own, so B and C stay unclipped. + let holder = m.parent.unwrap(); + let kids: Vec<&str> = doc.node(holder).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(kids, ["Mask", "A"]); + assert_ne!(node(&doc, "B").parent, Some(holder)); + let bb = doc.world_bounds(node(&doc, "A").id).unwrap(); + assert_eq!((bb.x0, bb.y0), (0.0, 0.0)); +} + +#[test] +fn display_p3_document() { + let mut d = document(&["P"]); + d["colorSpace"] = json!(2); + let (doc, _) = open_doc(d, vec![], &[]); + assert_eq!(doc.color_profile, ColorProfile::DisplayP3); +} + +#[test] +fn style_on_frames_and_text_kinds_untouched() { + // An artboard keeps its background when its style has no fills. + let (doc, _) = open(vec![]); + assert!(matches!(node(&doc, "Board").kind, NodeKind::Frame)); + assert_eq!(node(&doc, "Board").fills.len(), 1); +} + +#[test] +fn shape_group_masks_clip() { + // Sketch 51 and older: the mask is a shape group. + let inner = with(r("Outline"), "booleanOperation", json!(-1)); + let mask = with(shape_group("Card", 0.0, 0.0, 100.0, 50.0, vec![inner]), "hasClippingMask", json!(true)); + let a = rect("A", 0.0, 0.0, 300.0, 300.0); + let b = with(r("B"), "shouldBreakMaskChain", json!(true)); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 300.0, vec![mask, a, b])]); + let m = node(&doc, "Card"); + assert!(m.is_mask); + let holder = doc.node(m.parent.unwrap()).unwrap(); + let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(kids, ["Card", "A"]); +} + +#[test] +fn masked_icon_keeps_its_geometry() { + let oval = { + let mut o = with(rect("Path", 0.0, 0.0, 67.0, 67.0), "booleanOperation", json!(-1)); + o["_class"] = json!("oval"); + o + }; + let mask = styled( + with(shape_group("Circle", 0.0, 0.0, 67.0, 67.0, vec![oval]), "hasClippingMask", json!(true)), + "fills", + json!([solid_fill(0.9, 0.9, 0.9, 1.0)]), + ); + let p = |id: &str, x: f64, y: f64, w: f64, h: f64, op: i64| { + with(shape_path(id, x, y, w, h, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true), "booleanOperation", json!(op)) + }; + let content = styled( + shape_group( + "Body", + 12.0, + 12.0, + 45.0, + 62.0, + vec![p("P1", 9.0, 9.0, 22.0, 22.0, -1), p("P2", 25.0, 15.0, 20.0, 15.0, 0), p("P3", 0.0, 17.0, 31.0, 45.0, 0)], + ), + "fills", + json!([solid_fill(0.5, 0.5, 0.5, 1.0)]), + ); + let (doc, _) = open(vec![group("Icon", 152.0, 20.0, 67.0, 67.0, vec![mask, content])]); + let b = doc.world_bounds(node(&doc, "Circle").id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (152.0, 20.0, 67.0, 67.0), "mask"); + let b = doc.world_bounds(node(&doc, "Body").id).unwrap(); + assert!((b.x0 - 164.0).abs() < 1e-6 && (b.y0 - 41.0).abs() < 1e-6 && (b.width() - 45.0).abs() < 1e-6, "body {b:?}"); + assert!(matches!(node(&doc, "Circle").kind, NodeKind::Ellipse { .. })); +} + +#[test] +fn two_styled_masks_each_draw() { + let fill = json!([solid_fill(0.5, 0.5, 0.5, 1.0)]); + let m1 = styled(with(r("Mask1"), "hasClippingMask", json!(true)), "fills", fill.clone()); + let m2 = styled(with(rect("Mask2", 200.0, 0.0, 100.0, 50.0), "hasClippingMask", json!(true)), "fills", fill); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 50.0, vec![m1, r("A"), m2, rect("B", 200.0, 0.0, 10.0, 10.0)])]); + // The drawn copy of the second mask isn't inside the first mask's group. + let copy = doc.nodes().find(|n| n.name == "Mask2 (drawn)").unwrap(); + let first_group = node(&doc, "Mask1").parent.unwrap(); + assert_ne!(copy.parent, Some(first_group)); +} + +#[test] +fn bitmap_color_controls_become_filters() { + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let mut bitmap = r("Photo"); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + bitmap["style"]["colorControls"] = + json!({"_class": "colorControls", "isEnabled": true, "brightness": 0, "contrast": 1, "hue": 0.5, "saturation": 0}); + let (doc, warnings) = open_doc(document(&["P"]), vec![bitmap], &[("images/a.png", &png)]); + let PaintKind::Image(img) = &node(&doc, "Photo").fills[0].kind else { panic!("no image") }; + assert_eq!(img.filters.saturation, -1.0); + assert_eq!(img.filters.contrast, 0.0); + assert!(warnings.iter().any(|w| w.contains("hue")), "{warnings:?}"); +} + +/// An alpha mask (`clippingMaskMode` 1) only masks: Sketch doesn't draw its fill under the content. +#[test] +fn alpha_masks_draw_no_copy() { + let fill = json!([solid_fill(0.0, 0.0, 0.0, 1.0)]); + let m = styled(with(with(r("Fade"), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)), "fills", fill); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![m, r("Photo")])]); + assert!(!doc.nodes().any(|n| n.name == "Fade (drawn)")); + assert_eq!(node(&doc, "Fade").mask_type, figcraft_doc::MaskType::Alpha); +} + +/// A progressive opacity (Fade: `contextSettings.isProgressive` with a gradient) becomes an alpha +/// mask of that gradient over the layer, in a group in the layer's place. +#[test] +fn fades_become_alpha_masks() { + let fade = json!({"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1, "isProgressive": true, + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let l = styled(rect("Photo", 30.0, 40.0, 100.0, 50.0), "contextSettings", fade); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 200.0, 200.0, vec![l])]); + // The group takes the layer's name too. + let photo = doc.nodes().find(|n| n.name == "Photo" && matches!(n.kind, NodeKind::Rectangle)).unwrap(); + let wrap = doc.node(photo.parent.unwrap()).unwrap(); + assert_eq!(wrap.name, "Photo"); + assert!(matches!(wrap.kind, NodeKind::Group), "{:?}", wrap.kind); + let mask = doc.node(wrap.children[0]).unwrap(); + assert!(mask.is_mask && mask.mask_type == figcraft_doc::MaskType::Alpha); + assert!(matches!(mask.fills[0].kind, PaintKind::Gradient(_))); + assert_eq!(doc.world_bounds(mask.id), doc.world_bounds(photo.id)); + let b = doc.world_bounds(photo.id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (30.0, 40.0, 100.0, 50.0)); +} + +/// Sketch's linear gradients run square in pixels: on a 200×100 layer, from the top left to the +/// bottom right, the stripes are at right angles to that line in pixels, not in the layer's 0–1. +#[test] +fn linear_gradients_are_square_in_pixels() { + let g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 1.0)}]}}); + let (doc, _) = open(vec![styled(rect("Wide", 0.0, 0.0, 200.0, 100.0), "fills", json!([g]))]); + let PaintKind::Gradient(g) = &node(&doc, "Wide").fills[0].kind else { panic!("not a gradient") }; + // The line runs (200, 100) in pixels; square to it, (−100, 200), is (−0.5, 2) in 0–1. + let (wx, wy) = ((g.width.0 - g.from.0) * 200.0, (g.width.1 - g.from.1) * 100.0); + assert!((wx * 200.0 + wy * 100.0).abs() < 1e-6, "{g:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs new file mode 100644 index 000000000..dd8062827 --- /dev/null +++ b/crates/format/src/sketch/symbols.rs @@ -0,0 +1,520 @@ +//! Sketch symbols → REST components and instances. +//! +//! A `symbolMaster` (on any page, or copied from a library into `document.foreignSymbols`) is a +//! component; a `symbolInstance` names it by `symbolID`. REST instances carry their layers, so an +//! instance is its master's layers with the instance's `overrideValues` applied, laid out at the +//! master's size (the import then resizes them to the instance by their constraints). An override +//! is named `_`: `path` is the `do_objectID` of a layer of the master, or of +//! nested instances and then a layer of theirs, joined by `/`; properties: `stringValue` (text), +//! `symbolID` (swap a nested instance; empty hides it), `image`, `layerStyle` and `textStyle` +//! (shared style ids). + +use std::collections::BTreeMap; + +use figcraft_doc::geom::Affine; +use serde_json::{Map as JsonMap, Value, json}; + +use super::shared::Shared; +use super::tree::{Cx, base, bool_field, children, local, size, str_field}; + +/// Work units an override handed on to a nested instance costs (the JSON values it adds). +const OVERRIDE_COST: u64 = 8; +/// Most masters read from one file. +const MAX_MASTERS: usize = 100_000; + +/// Every symbol master of a file by `symbolID`, and the library ones no page holds. +#[derive(Default)] +pub(crate) struct Symbols { + masters: BTreeMap, + /// What expanding each master costs (see [`weight`]). + weights: BTreeMap, + /// Masters copied from libraries that no page holds (they get a page of their own), with the + /// libraries' names. + pub foreign: Vec, + pub libraries: Vec, +} + +impl Symbols { + /// The masters of `pages` and of the document's library copies. + pub(crate) fn read(document: &Value, pages: &[(String, Value)]) -> Symbols { + let mut s = Symbols::default(); + let mut found = Vec::new(); + for (_, p) in pages { + masters_in(p, &mut found, 0); + } + for m in found { + s.add(m.clone()); + } + for f in document.get("foreignSymbols").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() { + let Some(m) = f.get("symbolMaster") else { continue }; + let Some(id) = str_field(m, "symbolID") else { continue }; + if s.masters.contains_key(id) { + continue; + } + if let Some(lib) = str_field(f, "sourceLibraryName") + && !s.libraries.iter().any(|l| l == lib) + { + s.libraries.push(lib.chars().take(64).collect()); + } + s.add(m.clone()); + s.foreign.push(m.clone()); + } + s + } + + fn add(&mut self, m: Value) { + if self.masters.len() >= MAX_MASTERS { + return; + } + if let Some(id) = str_field(&m, "symbolID") + && !self.masters.contains_key(id) + { + self.weights.insert(id.to_string(), weight(&m, 0)); + self.masters.insert(id.to_string(), m); + } + } + + fn master(&self, symbol_id: &str) -> Option<&Value> { + self.masters.get(symbol_id) + } +} + +/// What copying and mapping `v` costs, in work units: one per JSON value, one per 8 bytes of text. +pub(crate) fn weight(v: &Value, depth: usize) -> u64 { + if depth > 256 { + return 1; + } + match v { + Value::String(s) => 1 + s.len() as u64 / 8, + Value::Array(a) => 1 + a.iter().map(|x| weight(x, depth + 1)).sum::(), + Value::Object(o) => 1 + o.iter().map(|(k, x)| k.len() as u64 / 8 + weight(x, depth + 1)).sum::(), + _ => 1, + } +} + +fn masters_in<'v>(l: &'v Value, out: &mut Vec<&'v Value>, depth: usize) { + if depth > 64 { + return; + } + for c in super::tree::layers(l) { + if str_field(c, "_class") == Some("symbolMaster") { + out.push(c); + } else { + masters_in(c, out, depth + 1); + } + } +} + +/// A master's background as REST fills (masters show one only when they have it on). +pub(crate) fn master_fills(m: &Value) -> Option { + if bool_field(m, "hasBackgroundColor") != Some(true) { + return None; + } + let c = m.get("backgroundColor").and_then(super::tree::color)?; + Some(json!([{"type": "SOLID", "color": c}])) +} + +/// What a master shows as a frame (its instances the same): clipping, and in Sketch 2025 and newer +/// its style (fills, borders, effects) and corners. +fn frame_look(cx: &mut Cx, m: &Value, w: f64, h: f64, node: &mut JsonMap) { + // `clippingBehavior` 2 doesn't clip. + node.insert("clipsContent".into(), json!(m.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + if m.get("groupBehavior").is_some() + && let Some(st) = m.get("style") + { + // Tint fills aren't painted (see `style::fills`). + super::style::apply(cx, st, w, h, node); + } + super::tree::style_corners(m, node); +} + +/// A symbol master as a COMPONENT node. +pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let mut node = base(l, "COMPONENT", parent, world, w, h, root); + frame_look(cx, l, w, h, &mut node); + if let Some(f) = master_fills(l) { + node.insert("fills".into(), f); + } + super::tree::extras(cx, l, h, &mut node); + let sid = str_field(l, "symbolID").unwrap_or("").to_string(); + // A graphic's fill tints its layers. + let tinted; + let l = match super::style::tint_of(l.get("style"), false).filter(|_| super::tree::spend_on_tint(cx, l)) { + Some(t) => { + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + tinted = c; + &tinted + } + _ => l, + }; + cx.chain.push(sid); + let kids = children(cx, l, world, (w, h), depth, false); + cx.chain.pop(); + node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); + Some(Value::Object(node)) +} + +/// A symbol instance as an INSTANCE node holding its master's layers with its overrides, or an +/// empty frame when its master isn't in the file. +pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let sid = str_field(l, "symbolID").unwrap_or(""); + let cost = cx.symbols.weights.get(sid).copied().unwrap_or(0); + if cx.symbols.master(sid).is_some() && !cx.spend(cost) { + let mut node = base(l, "FRAME", parent, world, w, h, root); + node.insert("children".into(), json!([])); + return Some(Value::Object(node)); + } + let Some(m) = cx.symbols.master(sid).cloned() else { + cx.warn("instances of symbols that aren't in the file were imported as empty frames"); + let mut node = base(l, "FRAME", parent, world, w, h, root); + node.insert("children".into(), json!([])); + return Some(Value::Object(node)); + }; + if cx.chain.iter().any(|c| c == sid) { + let name: String = str_field(&m, "name").unwrap_or("").chars().take(64).collect(); + cx.warn(format!("symbol {name} contains itself; the inner copy was left out")); + return None; + } + // Sketch 50 stores "not scaled" as 0. + if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && s != 0.0 && (s - 1.0).abs() > 1e-9) { + cx.warn("scaled symbol instances were imported at their size, unscaled"); + } + let mut node = base(l, "INSTANCE", parent, world, w, h, root); + node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); + frame_look(cx, &m, w, h, &mut node); + // Instances show the master's background only when it says so. + if bool_field(&m, "includeBackgroundColorInInstance") == Some(true) + && let Some(f) = master_fills(&m) + { + node.insert("fills".into(), f); + } + // An instance's fill is a tint for its layers, not a paint of its own. + if let Some(st) = l.get("style") { + let mut st = st.clone(); + if let Some(o) = st.as_object_mut() { + o.remove("fills"); + } + super::style::apply(cx, &st, w, h, &mut node); + } + super::tree::extras(cx, l, h, &mut node); + // An instance shows its master's grids (else they'd differ, and count as an override). + super::proto::layout_grids(&m, h, &mut node); + let overrides = overrides(l); + let nested = nested(&overrides); + let mut copy = m; + let mut budget = cx.work_left(); + if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { + apply(layers, &overrides, &nested, &cx.shared, &cx.symbols, 0, &mut budget); + } + let used = cx.work_left().saturating_sub(budget); + cx.spend(used); + if budget == 0 { + // The budget ran out handing overrides on: noted. + cx.spend(1); + } + // The tint: the instance's own (a colour override on it, or its fill), else a graphic master's. + let own = str_field(l, "do_objectID").map(|id| format!("{id}_fillColor")); + let tint = own + .and_then(|k| overrides.get(&k).filter(|c| c.is_object()).cloned()) + .or_else(|| super::style::tint_of(l.get("style"), true)) + .or_else(|| super::style::tint_of(copy.get("style"), false)); + if let Some(t) = tint + && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) + { + super::style::tint_layers(layers, &t, 0); + } + // The master's layers laid out for the instance's size, as Sketch does it. + let (mw, mh) = size(©); + // A graphic's layers resize in proportion, whatever their pins. + let graphic = copy.get("groupBehavior").and_then(Value::as_i64) == Some(2); + // A stack's layers are placed by its auto layout (when the file is opened), not by their pins. + let stacked = !graphic && super::stack::is_stack(©); + if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + && !stacked + && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) + { + resize(layers, (mw, mh), (w, h), cx.legacy_resize || graphic, 0); + } + cx.chain.push(sid.to_string()); + let mut kids = children(cx, ©, world, (w, h), depth, false); + cx.chain.pop(); + node.insert("children".into(), Value::Array(std::mem::take(&mut kids))); + // The master's stack lays the instance out too (matched by the master's layer ids), sized as + // the instance says. + for k in ["horizontalSizing", "verticalSizing"] { + if let Some(v) = l.get(k) { + copy[k] = v.clone(); + } + } + super::stack::apply(©, &mut node); + let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); + for k in node.get_mut("children").and_then(Value::as_array_mut).into_iter().flatten() { + prefix_ids(k, &prefix, 0); + } + Some(Value::Object(node)) +} + +/// Lay `layers` (the children of a box of `old` size) out for the box at `new` size, the way Sketch +/// resizes a symbol instance's layers: groups pass the change on to their own layers. Files older +/// than format 110 scale everything; newer ones follow each layer's `resizingConstraint`. +fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + let mask = if legacy { 63 } else { super::tree::pin_mask(l) }; + let Some(f) = l.get_mut("frame").and_then(Value::as_object_mut) else { continue }; + let n = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let (x, y, w, h) = (n("x"), n("y"), n("width").max(0.0), n("height").max(0.0)); + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + let pinned = |bit: i64| mask & bit == 0; + let (nx, nw) = resize_axis(x, w, old.0, new.0, (pinned(4), pinned(2), pinned(1))); + let (ny, nh) = resize_axis(y, h, old.1, new.1, (pinned(32), pinned(16), pinned(8))); + for (k, v) in [("x", nx), ("y", ny), ("width", nw), ("height", nh)] { + f.insert(k.into(), json!(v)); + } + // A graphic's layers resize in proportion, whatever their pins. + let graphic = l.get("groupBehavior").and_then(Value::as_i64) == Some(2); + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + resize(kids, (w, h), (nw, nh), legacy || graphic, depth + 1); + } + } +} + +/// One axis of [`resize`]: a layer at `pos` of `size` in a parent going from `old` to `new`, with +/// its start, size and end pinned or not. Nothing pinned (or everything): it scales. One side +/// pinned: the size and the free side share the change, in proportion to their lengths. Only the +/// size fixed: its centre scales. Both sides pinned: the size takes the change. +fn resize_axis(pos: f64, size: f64, old: f64, new: f64, (start, fixed, end): (bool, bool, bool)) -> (f64, f64) { + if old <= 1e-9 || !new.is_finite() { + return (pos, size); + } + let k = new / old; + let d = new - old; + let far = old - pos - size; + let share = |part: f64, other: f64| if part + other > 1e-9 { d * part / (part + other) } else { d }; + let (p, s) = match (start, fixed, end) { + (true, false, false) => (pos, size + share(size, far)), + (false, false, true) => { + let s = size + share(size, pos); + (new - far - s, s) + } + (false, true, false) => ((pos + size / 2.0) * k - size / 2.0, size), + (true, false, true) => (pos, size + d), + (true, true, false) => (pos, size), + (false, true, true) => (new - far - size, size), + _ => (pos * k, size * k), + }; + (if p.is_finite() { p } else { pos }, if s.is_finite() { s.max(0.0) } else { size }) +} + +/// An instance's overrides by name (`_` → value). +fn overrides(l: &Value) -> BTreeMap { + l.get("overrideValues") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .take(100_000) + .filter_map(|o| Some((str_field(o, "overrideName")?.to_string(), o.get("value")?.clone()))) + .collect() +} + +/// The overrides of layers inside nested instances, by the nested instance's id: `X/rest_prop` → +/// `X` → [(`rest_prop`, value)]. +fn nested(ov: &BTreeMap) -> BTreeMap<&str, Vec<(&str, &Value)>> { + let mut out: BTreeMap<&str, Vec<(&str, &Value)>> = BTreeMap::new(); + for (k, v) in ov { + if let Some((first, rest)) = k.split_once('/') { + out.entry(first).or_default().push((rest, v)); + } + } + out +} + +/// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested +/// instances for the layers inside them (an outer override wins over the nested instance's own). +/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget` plus its +/// value's [`weight`]; once it is spent, the rest aren't handed on. +fn apply( + layers: &mut [Value], + ov: &BTreeMap, + nested: &BTreeMap<&str, Vec<(&str, &Value)>>, + shared: &Shared, + symbols: &Symbols, + depth: usize, + budget: &mut u64, +) { + if depth > 64 || ov.is_empty() { + return; + } + for l in layers { + let Some(id) = str_field(l, "do_objectID").map(str::to_string) else { continue }; + let get = |prop: &str| ov.get(&format!("{id}_{prop}")); + match str_field(l, "_class") { + Some("symbolInstance") => { + match get("symbolID").map(|v| v.as_str().unwrap_or("")) { + Some("") => l["isVisible"] = json!(false), + // A swap to a symbol the file doesn't have keeps the original, as in Sketch. + Some(other) if symbols.master(other).is_some() => l["symbolID"] = json!(other), + _ => {} + } + let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default(); + // Each costs its copy's size: deep chains of large overrides use up the budget. + let mut take = 0; + for (_, v) in inner { + let cost = OVERRIDE_COST + weight(v, 0); + if cost > *budget { + break; + } + *budget -= cost; + take += 1; + } + if take > 0 { + let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); + own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); + l["overrideValues"] = Value::Array(own); + } + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on an instance is its tint. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) { + super::style::set_tint(l, c); + } + continue; + } + Some("text") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + if let Some(s) = get("stringValue").and_then(Value::as_str) { + set_string(l, s); + } + if let Some(c) = get("textColor").filter(|c| c.is_object()) { + super::style::tint_layers(std::slice::from_mut(l), c, 0); + } + } + Some("bitmap") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) { + l["image"] = img.clone(); + } + } + _ => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on a shape or group: its first fill's colour. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) + && let Some(f) = l + .get_mut("style") + .and_then(|s| s.get_mut("fills")) + .and_then(Value::as_array_mut) + .and_then(|f| f.first_mut()) + .and_then(Value::as_object_mut) + { + f.insert("fillType".into(), json!(0)); + f.insert("color".into(), c.clone()); + } + } + } + for (prop, text) in [("layerStyle", false), ("textStyle", true)] { + let sid = get(prop).and_then(Value::as_str); + // Shared styles that aren't objects are junk: left alone. + let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared.style(text, sid).filter(|s| s.is_object())?))) else { continue }; + let mut style = style.clone(); + if let Some(m) = style.as_object_mut() { + // The layer now uses that shared style: it links to it. + m.insert("sharedObjectID".into(), json!(sid)); + } + if text + && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) + && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) + { + for r in runs.iter_mut().filter_map(Value::as_object_mut) { + r.insert("attributes".into(), enc.clone()); + } + } + if let Some(m) = l.as_object_mut() { + m.insert("style".into(), style); + } + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + apply(kids, ov, nested, shared, symbols, depth + 1, budget); + } + } +} + +/// A visibility override's value: "0", 0 or false hide. +fn visible(v: &Value) -> bool { + !matches!(v, Value::Bool(false)) && v.as_str() != Some("0") && v.as_i64() != Some(0) +} + +/// A text layer's string replaced, in the style of its first run. +fn set_string(l: &mut Value, s: &str) { + let units = s.encode_utf16().count(); + let first = l.get("attributedString").and_then(|a| a.get("attributes")).and_then(Value::as_array).and_then(|a| a.first()).cloned(); + let Some(a) = l.get_mut("attributedString").and_then(Value::as_object_mut) else { return }; + a.insert("string".into(), json!(s)); + let runs = match first { + Some(Value::Object(mut r)) => { + r.insert("location".into(), json!(0)); + r.insert("length".into(), json!(units)); + vec![Value::Object(r)] + } + _ => Vec::new(), + }; + a.insert("attributes".into(), Value::Array(runs)); +} + +/// Make the ids of an instance's layers its own (`;`): the master's layers keep +/// theirs. +fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) { + if depth > 256 { + return; + } + let Some(m) = v.as_object_mut() else { return }; + if let Some(id) = m.get("id").and_then(Value::as_str).map(str::to_string) { + m.insert("id".into(), json!(format!("{prefix};{id}"))); + } + if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) { + for k in kids { + prefix_ids(k, prefix, depth + 1); + } + } +} + +/// Library masters no page holds: a page of their own, side by side. +pub(crate) fn library_page(cx: &mut Cx, masters: &[Value]) -> Value { + let mut x = 0.0; + let mut kids = Vec::new(); + for m in masters { + let mut placed = m.clone(); + let (w, _) = size(m); + if let Some(f) = placed.get_mut("frame").and_then(Value::as_object_mut) { + f.insert("x".into(), json!(x)); + f.insert("y".into(), json!(0.0)); + } + x += w + 100.0; + if let Some(node) = component(cx, &placed, Affine::IDENTITY, 0, true) { + kids.push(node); + } + } + let mut page = JsonMap::new(); + page.insert("type".into(), json!("CANVAS")); + page.insert("id".into(), json!("library-symbols")); + page.insert("name".into(), json!("Library symbols")); + page.insert("children".into(), Value::Array(kids)); + Value::Object(page) +} diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs new file mode 100644 index 000000000..7c2d73db4 --- /dev/null +++ b/crates/format/src/sketch/symbols_tests.rs @@ -0,0 +1,303 @@ +//! Sketch symbols → components and instances with overrides. + +use figcraft_doc::{Document, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::{import, import_within_nodes}; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; + +/// One page with `layers`, one "Symbols" page with `masters`, and `document` changes. +fn open_doc(doc_json: Value, layers: Vec, masters: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, doc_json, &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap() +} + +/// [`open`] for a file saved by another Sketch. +fn open_version(app: &str, version: u64, layers: Vec, masters: Vec) -> (Document, Vec) { + let z = sketch_zip(app, version, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap() +} + +fn open(layers: Vec, masters: Vec) -> (Document, Vec) { + let mut d = document(&["P", "S"]); + d["do_objectID"] = json!("DOC"); + open_doc(d, layers, masters) +} + +fn named<'d>(doc: &'d Document, name: &str) -> Vec<&'d Node> { + doc.nodes().filter(|n| n.name == name).collect() +} + +fn inside<'d>(doc: &'d Document, root: &Node, name: &str) -> &'d Node { + doc.subtree(root.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn chars(n: &Node) -> &str { + match &n.kind { + NodeKind::Text(td) => &td.characters, + other => panic!("{other:?}"), + } +} + +fn instance_of<'d>(doc: &'d Document, name: &str) -> (&'d Node, &'d Node) { + let inst = named(doc, name).into_iter().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no instance {name}")); + let NodeKind::Instance { component, .. } = inst.kind else { panic!("not an instance") }; + (inst, doc.node(component).unwrap()) +} + +fn button() -> Value { + master("M1", "S1", "Button", 100.0, 40.0, vec![text("Label", 10.0, 10.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, BLACK)])]) +} + +#[test] +fn master_is_component_instance_links() { + let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![button()]); + let (inst, main) = instance_of(&doc, "I1"); + assert!(matches!(main.kind, NodeKind::Component { .. })); + assert_eq!(main.name, "Button"); + assert_eq!(chars(inside(&doc, inst, "Label")), "Go"); +} + +#[test] +fn string_override() { + let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[("Label_stringValue", json!("OK"))])], vec![button()]); + let (inst, main) = instance_of(&doc, "I1"); + assert_eq!(chars(inside(&doc, inst, "Label")), "OK"); + assert_eq!(chars(inside(&doc, main, "Label")), "Go"); +} + +#[test] +fn nested_symbol_swap_and_hide() { + let icon_a = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]); + let icon_b = master("MB", "SB", "Icon B", 20.0, 20.0, vec![rect("Dot B", 0.0, 0.0, 20.0, 20.0)]); + let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]); + let swapped = instance("Swapped", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("SB"))]); + let hidden = instance("Hidden", "SC", 0.0, 100.0, 100.0, 40.0, &[("N_symbolID", json!(""))]); + let (doc, _) = open(vec![swapped, hidden], vec![icon_a, icon_b, card]); + let (sw, _) = instance_of(&doc, "Swapped"); + assert!(doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot B")); + assert!(!doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A")); + let (hd, _) = instance_of(&doc, "Hidden"); + let nested = doc.node(hd.children[0]).unwrap(); + assert!(!nested.visible); +} + +#[test] +fn nested_path_override() { + let inner = master("MB", "SB", "Badge", 50.0, 20.0, vec![text("Count", 0.0, 0.0, 50.0, 20.0, "1", &[(0, 1, "Helvetica", 12.0, BLACK)])]); + let outer = master("MA", "SA", "Row", 100.0, 20.0, vec![instance("N", "SB", 50.0, 0.0, 50.0, 20.0, &[])]); + let (doc, _) = open(vec![instance("I1", "SA", 0.0, 0.0, 100.0, 20.0, &[("N/Count_stringValue", json!("42"))])], vec![inner, outer]); + let (inst, _) = instance_of(&doc, "I1"); + assert_eq!(chars(inside(&doc, inst, "Count")), "42"); +} + +#[test] +fn image_override() { + let mut bitmap = rect("Photo", 0.0, 0.0, 40.0, 40.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + let m = master("M", "S", "Avatar", 40.0, 40.0, vec![bitmap]); + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(1, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let mut png2 = Vec::new(); + image::RgbaImage::new(2, 1).write_to(&mut std::io::Cursor::new(&mut png2), image::ImageFormat::Png).unwrap(); + let inst = instance("I1", "S", 0.0, 0.0, 40.0, 40.0, &[("Photo_image", json!({"_class": "MSJSONFileReference", "_ref": "images/b.png"}))]); + let z = sketch_zip( + "50.2", + 103, + document(&["P", "S"]), + &[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])], + &[("images/a.png", &png), ("images/b.png", &png2)], + ); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(doc.images.len(), 2); +} + +#[test] +fn foreign_symbols_are_components() { + let mut d = document(&["P", "S"]); + d["foreignSymbols"] = json!([{"_class": "MSImmutableForeignSymbol", "sourceLibraryName": "Kit", "symbolMaster": button()}]); + let (doc, warnings) = open_doc(d, vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![]); + let (_, main) = instance_of(&doc, "I1"); + assert_eq!(main.name, "Button"); + let page = doc.pages.iter().copied().find(|p| doc.is_ancestor(*p, main.id)).unwrap(); + assert_eq!(doc.node(page).unwrap().name, "Library symbols"); + assert!(warnings.iter().any(|w| w.contains("Kit")), "{warnings:?}"); +} + +#[test] +fn missing_master_warns() { + let (doc, warnings) = open(vec![instance("Lost", "NOPE", 0.0, 0.0, 30.0, 30.0, &[])], vec![]); + let n = named(&doc, "Lost")[0]; + assert!(matches!(n.kind, NodeKind::Frame)); + assert_eq!((n.width, n.height), (30.0, 30.0)); + assert!(warnings.iter().any(|w| w.contains("symbol")), "{warnings:?}"); +} + +#[test] +fn self_containing_master_stops() { + let a = master("MA", "SA", "Loop A", 50.0, 50.0, vec![instance("Self", "SA", 0.0, 0.0, 10.0, 10.0, &[])]); + let b = master("MB", "SB", "Loop B", 50.0, 50.0, vec![instance("ToC", "SC", 0.0, 0.0, 10.0, 10.0, &[])]); + let c = master("MC", "SC", "Loop C", 50.0, 50.0, vec![instance("ToB", "SB", 0.0, 0.0, 10.0, 10.0, &[])]); + let (_, warnings) = open(vec![instance("I1", "SA", 0.0, 0.0, 50.0, 50.0, &[]), instance("I2", "SB", 100.0, 0.0, 50.0, 50.0, &[])], vec![a, b, c]); + assert!(warnings.iter().any(|w| w.contains("contains itself")), "{warnings:?}"); +} + +#[test] +fn instance_explosion_is_bounded() { + let big = master("M", "S", "Big", 100.0, 100.0, (0..1000).map(|i| rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0)).collect()); + let many: Vec = (0..1000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 100.0, 100.0, &[])).collect(); + let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![big])], &[]); + let (doc, warnings) = import_within_nodes(&z, "f", 20_000).unwrap(); + assert!(doc.nodes().count() <= 20_100); + assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + +#[test] +fn resized_instance_follows_constraints() { + // Pinned right with a fixed width (bits 1 and 2 cleared); a Sketch 64 file. + let pinned = with(rect("Tag", 80.0, 0.0, 20.0, 40.0), "resizingConstraint", json!(60)); + let m = master("M", "S", "Bar", 100.0, 40.0, vec![pinned]); + let (doc, _) = open_version("64", 125, vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]); + let (inst, _) = instance_of(&doc, "Wide"); + let tag = inside(&doc, inst, "Tag"); + let b = doc.world_bounds(tag.id).unwrap(); + assert!((b.x0 - 180.0).abs() < 1e-6 && (b.width() - 20.0).abs() < 1e-6, "{b:?}"); +} + +/// Overrides pointing at junk (a shared style that isn't an object, text runs that aren't) never +/// panic. +#[test] +fn override_application_survives_junk() { + let mut d = document(&["P", "S"]); + d["layerStyles"]["objects"] = json!([{"_class": "sharedStyle", "do_objectID": "S1", "name": "Junk", "value": 5}]); + d["layerTextStyles"]["objects"] = + json!([{"_class": "sharedStyle", "do_objectID": "T1", "name": "Junk text", "value": {"textStyle": {"encodedAttributes": {}}}}]); + let mut t = text("Label", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]); + t["attributedString"]["attributes"] = json!([5, "x", null]); + let m = master("M", "S", "Junk master", 50.0, 20.0, vec![rect("C", 0.0, 0.0, 10.0, 10.0), t]); + let inst = instance( + "I1", + "S", + 0.0, + 0.0, + 50.0, + 20.0, + &[("C_layerStyle", json!("S1")), ("Label_textStyle", json!("T1")), ("Label_stringValue", json!("y"))], + ); + let z = sketch_zip("50.2", 103, d, &[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])], &[]); + assert!(import(&z, "f").is_ok()); +} + +/// A huge master expanded by many instances stops at the file's work budget. +#[test] +fn expansion_work_is_bounded() { + let long = "x".repeat(200_000); + let m = master("M", "S", "Heavy", 50.0, 20.0, vec![text("Long", 0.0, 0.0, 50.0, 20.0, &long, &[(0, 200_000, "Helvetica", 9.0, BLACK)])]); + let many: Vec = (0..3000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 50.0, 20.0, &[])).collect(); + let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![m])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); +} + +/// Outer overrides fanned out to many nested instances stop at the work budget. +#[test] +fn override_fan_out_is_bounded() { + let inner = master("MB", "SB", "Cell", 10.0, 10.0, vec![text("T", 0.0, 0.0, 10.0, 10.0, "1", &[(0, 1, "Helvetica", 9.0, BLACK)])]); + let cells: Vec = (0..3000).map(|_| instance("X", "SB", 0.0, 0.0, 10.0, 10.0, &[])).collect(); + let grid = master("MA", "SA", "Grid", 100.0, 100.0, cells); + let overrides: Vec<(String, Value)> = (0..3000).map(|i| (format!("X/N{i}_stringValue"), json!("v"))).collect(); + let refs: Vec<(&str, Value)> = overrides.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let z = sketch_zip( + "50.2", + 103, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "SA", 0.0, 0.0, 100.0, 100.0, &refs)]), page("S", "Symbols", vec![inner, grid])], + &[], + ); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); +} + +/// Sketch 50 stores "not scaled" as `scale: 0`. +#[test] +fn scale_zero_is_unscaled() { + let mut i = instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[]); + i["scale"] = json!(0); + let (_, warnings) = open(vec![i], vec![button()]); + assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}"); +} + +/// A master's background shows in its instances only when Sketch says so. +#[test] +fn master_background_in_instances_only_when_included() { + let bg = |id: &str, sid: &str, include: bool| { + let mut m = master(id, sid, id, 20.0, 20.0, vec![]); + m["hasBackgroundColor"] = json!(true); + m["backgroundColor"] = json!({"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1}); + m["includeBackgroundColorInInstance"] = json!(include); + m + }; + let (doc, _) = open( + vec![instance("Hidden", "SH", 0.0, 0.0, 20.0, 20.0, &[]), instance("Shown", "SS", 50.0, 0.0, 20.0, 20.0, &[])], + vec![bg("MH", "SH", false), bg("MS", "SS", true)], + ); + let (hidden, main) = instance_of(&doc, "Hidden"); + assert!(hidden.fills.is_empty(), "{:?}", hidden.fills); + assert_eq!(main.fills.len(), 1, "the master shows its background"); + assert_eq!(instance_of(&doc, "Shown").0.fills.len(), 1); +} + +/// How Sketch lays out a resized instance's layers, measured on its renders: a master of 200×100 +/// with a 100×40 rectangle at (20, 30), shown at 320×60. +#[test] +fn resized_instances_lay_out_like_sketch() { + let case = |mask: i64, version: u64| { + let r = with(rect("R", 20.0, 30.0, 100.0, 40.0), "resizingConstraint", json!(mask)); + let m = master("M", "S", "Box", 200.0, 100.0, vec![r]); + let (doc, _) = open_version("64", version, vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])], vec![m]); + let (inst, _) = instance_of(&doc, "I"); + let b = doc.world_bounds(inside(&doc, inst, "R").id).unwrap(); + (b.x0, b.y0, b.width(), b.height()) + }; + let near = |a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)| { + (a.0 - b.0).abs() < 0.5 && (a.1 - b.1).abs() < 0.5 && (a.2 - b.2).abs() < 0.5 && (a.3 - b.3).abs() < 0.5 + }; + // Sketch 52 and newer (format 110+). + for (mask, want) in [ + (63, (32.0, 18.0, 160.0, 24.0)), + (59, (20.0, 18.0, 166.67, 24.0)), + (62, (40.0, 18.0, 200.0, 24.0)), + (61, (62.0, 18.0, 100.0, 24.0)), + (58, (20.0, 18.0, 220.0, 24.0)), + (57, (20.0, 18.0, 100.0, 24.0)), + (60, (140.0, 18.0, 100.0, 24.0)), + (54, (40.0, 12.86, 200.0, 17.14)), + (0, (32.0, 18.0, 160.0, 24.0)), + (47, (32.0, 10.0, 160.0, 40.0)), + ] { + let got = case(mask, 125); + assert!(near(got, want), "mask {mask}: {got:?} vs {want:?}"); + } + // Older files: everything scales. + for mask in [59, 62, 61, 0] { + let got = case(mask, 103); + assert!(near(got, (32.0, 18.0, 160.0, 24.0)), "legacy mask {mask}: {got:?}"); + } +} + +/// A nested swap to a symbol the file doesn't have keeps the original, as Sketch shows it. +#[test] +fn swap_to_a_missing_symbol_keeps_the_original() { + let icon = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]); + let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]); + let (doc, warnings) = open(vec![instance("I", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("GONE"))])], vec![icon, card]); + let (inst, _) = instance_of(&doc, "I"); + assert!(doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A")); + assert!(!warnings.iter().any(|w| w.contains("empty frames")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/tests.rs b/crates/format/src/sketch/tests.rs new file mode 100644 index 000000000..b4368de12 --- /dev/null +++ b/crates/format/src/sketch/tests.rs @@ -0,0 +1,201 @@ +//! `.sketch` import: detection, the archive, the version cut and pages, on fixtures built in code. + +use serde_json::json; + +use super::fixture::*; +use super::*; + +fn page_names(doc: &Document) -> Vec { + doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect() +} + +#[test] +fn detects_sketch_and_not_others() { + let z = sketch_zip("50.2", 103, document(&["P1"]), &[page("P1", "Page 1", vec![])], &[]); + assert!(is_sketch(&z)); + assert!(!is_sketch(b"fig-kiwi....")); + assert!(!is_sketch(b"{}")); + assert!(!is_sketch(b"")); + // A zip that isn't a Sketch file. + let mut other = Vec::new(); + { + use std::io::Write; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut other)); + w.start_file("canvas.fig", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(b"x").unwrap(); + w.finish().unwrap(); + } + assert!(!is_sketch(&other)); +} + +#[test] +fn pages_become_pages_in_document_order() { + let z = sketch_zip("50.2", 103, document(&["B", "A"]), &[page("A", "Alpha", vec![]), page("B", "Beta", vec![])], &[]); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(page_names(&doc), ["Beta", "Alpha"]); +} + +#[test] +fn older_than_48_is_refused() { + let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]); + let e = import(&z, "f").unwrap_err().to_string(); + assert!(e.contains(TOO_OLD), "{e}"); + let mut sqlite = b"SQLite format 3\0".to_vec(); + sqlite.resize(512, 0); + assert!(is_sketch(&sqlite)); + assert!(import(&sqlite, "f").unwrap_err().to_string().contains(TOO_OLD)); +} + +#[test] +fn archived_text_without_meta_version_is_refused() { + let text = json!({ + "_class": "text", "do_objectID": "T", "name": "T", + "frame": {"_class": "rect", "x": 0, "y": 0, "width": 10, "height": 10}, + "attributedString": {"_class": "MSAttributedString", "archivedAttributedString": {"_archive": "YnBsaXN0MDA="}}, + }); + let z = raw_zip(&document(&["P"]), &json!({"app": "com.bohemiancoding.sketch3"}), &[page("P", "P", vec![text])], &[]); + let e = import(&z, "f").unwrap_err().to_string(); + assert!(e.contains(TOO_OLD), "{e}"); +} + +#[test] +fn newer_than_tested_imports_with_one_warning() { + let z = sketch_zip("2099.1", NEWEST_TESTED_VERSION + 1, document(&["P"]), &[page("P", "P", vec![])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert_eq!(warnings.iter().filter(|w| w.contains("newer Sketch")).count(), 1, "{warnings:?}"); +} + +#[test] +fn junk_never_panics() { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![rect("R", 0.0, 0.0, 10.0, 10.0)])], &[]); + for cut in (0..z.len()).step_by(7) { + let _ = import(&z[..cut], "f"); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..200 { + let mut b = z.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % b.len(); + b[i] = (seed >> 8) as u8; + } + let _ = import(&b, "f"); + } + for doc in [json!([]), json!(null), json!({"pages": 5}), json!({"pages": [{"_ref": 7}, {"_ref": "pages/../../x"}]})] { + let _ = import(&sketch_zip("50.2", 103, doc, &[json!(5), json!({"do_objectID": "Q", "layers": "x"})], &[]), "f"); + } + let _ = import(&raw_zip(&document(&["P"]), &json!("x"), &[], &[]), "f"); +} + +/// A file using most of what the importer reads (Sketch 2025 frames and stacks, tints, symbols +/// with overrides, text, paths, gradients, masks, fades, grids) with each of its values in turn +/// replaced by a value of another type: every import ends without a panic. +#[test] +fn junk_values_never_panic() { + let text_layer = text("T", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![dot.clone()]); + icon["groupBehavior"] = json!(2); + icon["style"]["fills"] = json!([tint]); + let mut chip = stack( + master("MC", "SC", "Chip", 60.0, 24.0, vec![text_layer.clone(), instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[])]), + 0, + 1, + 1, + 6.0, + [8.0, 4.0, 10.0, 4.0], + ); + chip["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 2, "smoothing": 0.6}); + let over = [ + ("T_stringValue", json!("Longer")), + ("N_fillColor", color(0.0, 1.0, 0.0, 1.0)), + ("T_textColor", color(0.0, 0.0, 1.0, 1.0)), + ("N_isVisible", json!("0")), + ]; + let inst = instance("I", "SC", 0.0, 100.0, 200.0, 24.0, &over); + let grad = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 0.0)}]}}); + let mut faded = styled(rect("Faded", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad.clone()])); + faded["style"]["contextSettings"] = + json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, "gradient": grad["gradient"].clone()}); + let mask = + with(with(styled(rect("Mask", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad])), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)); + let path = shape_path("P", 0.0, 0.0, 20.0, 20.0, &[(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], true); + let mut g = group("G", 0.0, 0.0, 100.0, 100.0, vec![mask, faded, path, dot, text_layer]); + g["style"] = json!({"_class": "style", "fills": [tint_fill()]}); + let mut frame = stack(frame_group("F", 0.0, 0.0, 300.0, 300.0, vec![g, inst]), 1, 0, 3, 8.0, [0.0; 4]); + frame["layout"] = + json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 20, "gutterWidth": 10, "numberOfColumns": 4, "drawVertical": true}); + let pages = [page("P", "Page", vec![frame]), page("S", "Symbols", vec![icon, chip])]; + // Every value's path, with each junk value in turn (a deterministic sample keeps it quick). + fn paths(v: &serde_json::Value, at: &mut Vec, out: &mut Vec>) { + out.push(at.clone()); + match v { + serde_json::Value::Object(m) => { + for (k, c) in m { + at.push(k.clone()); + paths(c, at, out); + at.pop(); + } + } + serde_json::Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + at.push(i.to_string()); + paths(c, at, out); + at.pop(); + } + } + _ => {} + } + } + let junk = [json!(null), json!(5), json!(-1e300), json!("x"), json!(true), json!([]), json!({}), json!([1, "a"])]; + for (pi, p) in pages.iter().enumerate() { + let mut all = Vec::new(); + paths(p, &mut Vec::new(), &mut all); + for (n, at) in all.iter().enumerate().skip(1) { + let mut changed = pages.clone(); + let mut v = &mut changed[pi]; + for k in at { + v = match v { + serde_json::Value::Object(m) => m.get_mut(k).unwrap(), + serde_json::Value::Array(a) => &mut a[k.parse::().unwrap()], + _ => panic!("a path only runs through objects and arrays"), + }; + } + *v = junk[n % junk.len()].clone(); + let _ = import(&sketch_zip("2026.3.1", 196, document(&["P", "S"]), &changed, &[]), "f"); + } + } +} + +fn tint_fill() -> serde_json::Value { + let mut f = solid_fill(0.0, 0.0, 1.0, 1.0); + f["layeringType"] = json!(1); + f +} + +#[test] +fn deep_nesting_does_not_fail_the_file() { + let mut deep = rect("R", 0.0, 0.0, 1.0, 1.0); + for i in 0..300 { + deep = group(&format!("G{i}"), 0.0, 0.0, 1.0, 1.0, vec![deep]); + } + let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Deep", vec![deep]), page("B", "Normal", vec![])], &[]); + let (doc, warnings) = import(&z, "f").unwrap(); + assert!(page_names(&doc).contains(&"Normal".to_string())); + assert!(!warnings.is_empty()); +} + +#[test] +fn members_over_budget_are_skipped() { + let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Big", vec![]), page("B", "Small", vec![])], &[]); + let (doc, warnings) = import_within(&z, "f", 200).unwrap(); + // Each page JSON is larger than 200 bytes: both are skipped, with a warning; nothing panics. + assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}"); + assert!(!page_names(&doc).contains(&"Big".to_string())); +} diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs new file mode 100644 index 000000000..74cfa21c9 --- /dev/null +++ b/crates/format/src/sketch/text.rs @@ -0,0 +1,236 @@ +//! Sketch text layers → REST text nodes. +//! +//! A text layer's `attributedString` holds the `string` and `attributes`: runs given by `location` +//! and `length` in UTF-16 code units (like the REST importer's per-character style ids), each with +//! a font (PostScript name and size), colour, kerning, paragraph style, decorations and text +//! transform. `textBehaviour` sizes the box (0 auto width, 1 fixed width, 2 fixed); the style's +//! `textStyle` gives the vertical alignment. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, color}; + +/// Most characters read from one text layer (the REST importer's own limit). +const MAX_CHARS: usize = 1 << 20; +/// Most runs read from one text layer. +const MAX_RUNS: usize = 10_000; + +/// Fill `node` (a TEXT node) from text layer `l`. +pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let a = l.get("attributedString"); + let string: String = a.and_then(|a| a.get("string")).and_then(Value::as_str).unwrap_or("").chars().take(MAX_CHARS).collect(); + let units = string.encode_utf16().count(); + let runs: &[Value] = a.and_then(|a| a.get("attributes")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + if runs.len() > MAX_RUNS { + cx.warn(format!("text with more than {MAX_RUNS} styled runs keeps the first ones")); + } + let fallback = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("encodedAttributes")); + let styles: Vec<(usize, usize, Value)> = runs + .iter() + .take(MAX_RUNS) + .filter_map(|r| { + let start = usize::try_from(r.get("location")?.as_u64()?).ok()?.min(units); + let len = usize::try_from(r.get("length")?.as_u64()?).unwrap_or(usize::MAX); + Some((start, start.saturating_add(len).min(units), type_style(cx, r.get("attributes")?))) + }) + .collect(); + let base = styles.first().map(|s| s.2.clone()).or_else(|| fallback.map(|f| type_style(cx, f))).unwrap_or_else(|| type_style(cx, &json!({}))); + let mut style = base.clone(); + let mut fills = None; + if let Some(m) = style.as_object_mut() { + fills = m.remove("fills"); + // Paragraph settings are the first run's. + let paragraph = runs.first().and_then(|r| r.get("attributes")).or(fallback).and_then(|a| a.get("paragraphStyle")); + let align = match paragraph.and_then(|p| p.get("alignment")).and_then(Value::as_i64) { + Some(1) => "RIGHT", + Some(2) => "CENTER", + Some(3) => "JUSTIFIED", + _ => "LEFT", + }; + m.insert("textAlignHorizontal".into(), json!(align)); + if let Some(sp) = paragraph.and_then(|p| p.get("paragraphSpacing")).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) { + m.insert("paragraphSpacing".into(), json!(sp.min(1e4))); + } + let vertical = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("verticalAlignment")).and_then(Value::as_i64); + m.insert( + "textAlignVertical".into(), + json!(match vertical { + Some(1) => "CENTER", + Some(2) => "BOTTOM", + _ => "TOP", + }), + ); + // Sketch 2025 and newer size text by `horizontalSizing`/`verticalSizing` (1 fits). + let fits = |k: &str| l.get(k).and_then(Value::as_i64) == Some(1); + let resize = match l.get("textBehaviour").and_then(Value::as_i64) { + Some(1) => "HEIGHT", + Some(2) => "NONE", + Some(_) => "WIDTH_AND_HEIGHT", + None if l.get("horizontalSizing").is_none() || fits("horizontalSizing") => "WIDTH_AND_HEIGHT", + None if fits("verticalSizing") => "HEIGHT", + None => "NONE", + }; + m.insert("textAutoResize".into(), json!(resize)); + } + node.insert("characters".into(), json!(string)); + node.insert("style".into(), style); + if let Some(f) = fills { + node.insert("fills".into(), f); + } + // Runs that differ from the first: a style id per UTF-16 unit. + let mut ids = vec![0u64; units]; + let mut table = JsonMap::new(); + for (start, end, s) in &styles { + if *s == base { + continue; + } + let id = table.len() as u64 + 1; + table.insert(id.to_string(), s.clone()); + for slot in ids.get_mut(*start..*end).into_iter().flatten() { + *slot = id; + } + } + if !table.is_empty() { + node.insert("characterStyleOverrides".into(), json!(ids)); + node.insert("styleOverrideTable".into(), Value::Object(table)); + } +} + +/// How far a text layer moves so its lines sit where Sketch draws them. With a fixed line height, +/// Sketch puts a line's extra height (line height − the font's own, taken as 1.2 × its size) above +/// the text (`lineSpacingBehaviour` 0) or below it (1); FigCraft, like Sketch's default (2), puts +/// half on each side. Every line gets the same extra, so the whole layer moves. +pub(crate) fn baseline_shift(l: &Value) -> f64 { + let first = l + .get("attributedString") + .and_then(|a| a.get("attributes")) + .and_then(Value::as_array) + .and_then(|r| r.first()) + .and_then(|r| r.get("attributes")); + let Some(a) = first else { return 0.0 }; + let size = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")).and_then(|f| f.get("size")).and_then(Value::as_f64); + let line = a.get("paragraphStyle").and_then(|p| p.get("maximumLineHeight").or_else(|| p.get("minimumLineHeight"))).and_then(Value::as_f64); + let (Some(size), Some(line)) = (size.filter(|s| s.is_finite() && *s > 0.0), line.filter(|v| v.is_finite() && *v > 0.0)) else { return 0.0 }; + let extra = (line - size * 1.2).clamp(-1e4, 1e4); + match l.get("lineSpacingBehaviour").and_then(Value::as_i64) { + Some(0) => extra / 2.0, + Some(1) => -extra / 2.0, + _ => 0.0, + } +} + +/// A run's attributes as a REST type style (with its colour as `fills`). +pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { + let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); + let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or(""); + let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0); + let (family, style) = installed(cx, ps).unwrap_or_else(|| font_name(ps)); + if ps.is_empty() { + cx.warn("text without a font uses Helvetica"); + } + let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)}); + // Every field is written, neutral when the run has none: a run's style starts from the text's + // (its first run's), and an unwritten field would keep that one's value. + s["fontVariations"] = font.and_then(|f| f.get("variation")).and_then(variations).unwrap_or_else(|| json!({})); + let p = a.get("paragraphStyle"); + let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) { + s["lineHeightPx"] = json!(lh.min(1e4)); + s["lineHeightUnit"] = json!("PIXELS"); + } else { + s["lineHeightUnit"] = json!("INTRINSIC_%"); + } + s["letterSpacing"] = json!(a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()).unwrap_or(0.0).clamp(-1e4, 1e4)); + let set = |k: &str| a.get(k).and_then(Value::as_i64).is_some_and(|v| v != 0); + s["textDecoration"] = json!(if set("underlineStyle") { + "UNDERLINE" + } else if set("strikethroughStyle") { + "STRIKETHROUGH" + } else { + "NONE" + }); + s["textCase"] = json!(match a.get("MSAttributedStringTextTransformAttribute").and_then(Value::as_i64) { + Some(1) => "UPPER", + Some(2) => "LOWER", + _ => "ORIGINAL", + }); + let c = a.get("MSAttributedStringColorAttribute").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0})); + let mut paint = json!({"type": "SOLID", "color": c}); + if let Some(var) = super::style::swatch(cx, a.get("MSAttributedStringColorAttribute")) { + paint["boundVariables"] = var; + } + s["fills"] = json!([paint]); + s +} + +/// A font's `variation` (axis values keyed by the axis tag read as a big-endian number, as Apple's +/// text engine keys them: 2003265652 is `wght`) as `{tag: value}`; `None` when it has none. +fn variations(v: &Value) -> Option { + let axes: JsonMap = v + .as_object()? + .iter() + .take(64) + .filter_map(|(k, val)| { + let tag = u32::try_from(k.parse::().ok()?).ok()?.to_be_bytes(); + let tag = std::str::from_utf8(&tag).ok().filter(|t| t.chars().all(|c| c.is_ascii_graphic()))?.to_string(); + let val = val.as_f64().filter(|x| x.is_finite())?; + Some((tag, json!(val.clamp(-1e5, 1e5)))) + }) + .collect(); + (!axes.is_empty()).then_some(Value::Object(axes)) +} + +/// The installed family and style of PostScript name `ps`, when the import has a font lookup and +/// the font is installed. +fn installed(cx: &mut Cx, ps: &str) -> Option<(String, String)> { + if ps.is_empty() { + return None; + } + if let Some(found) = cx.font_cache.get(ps) { + return found.clone(); + } + let found = cx.fonts.as_ref().and_then(|f| f(ps)); + // A file names a few hundred fonts at most; junk ones don't grow the cache past that. + if cx.font_cache.len() < 4096 { + cx.font_cache.insert(ps.to_string(), found.clone()); + } + found +} + +/// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", +/// "Bold"). Words of the family are split at case changes, which can't always match the real name +/// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed +/// fonts ignoring spaces. Apple's system fonts take the SF Pro names, PingFang its own. +pub(crate) fn font_name(ps: &str) -> (String, String) { + let (family, style) = ps.split_once('-').unwrap_or((ps, "")); + let style = if style.is_empty() { "Regular".to_string() } else { crate::fig::style_name(style) }; + let family = match family { + "" => "Helvetica".to_string(), + ".SFNSText" | ".SFUIText" | "SanFranciscoText" | ".SFProText" => "SF Pro Text".into(), + ".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(), + // Apple's CJK families whose names don't split at case changes. + "PingFangSC" | "PingFangTC" | "PingFangHK" => format!("PingFang {}", family.get(8..).unwrap_or("")), + // Apple's SF families: "SF" and then words ("SFUIText" → "SF UI Text"). + f if f.starts_with("SF") && f.chars().nth(2).is_some_and(char::is_uppercase) => format!("SF {}", words(f.get(2..).unwrap_or(""))), + f => words(f.trim_start_matches('.')), + }; + (family, style) +} + +/// "IBMPlexSans" → "IBM Plex Sans": a space before a capital that follows a small letter, and +/// before the last capital of a run of capitals followed by a small letter. +fn words(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len() + 4); + for (i, c) in chars.iter().enumerate() { + let prev = i.checked_sub(1).and_then(|p| chars.get(p)); + let next = chars.get(i + 1); + let split = c.is_uppercase() + && (prev.is_some_and(|p| p.is_lowercase()) || (prev.is_some_and(|p| p.is_uppercase()) && next.is_some_and(|n| n.is_lowercase()))); + if split { + out.push(' '); + } + out.push(*c); + } + out +} diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs new file mode 100644 index 000000000..0e8a4a279 --- /dev/null +++ b/crates/format/src/sketch/text_tests.rs @@ -0,0 +1,273 @@ +//! Sketch text layers → text with styled runs; font PostScript names → family and style. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, LetterSpacing, LineHeight, NodeKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; +use super::text::font_name; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; +const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0]; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap() +} + +fn data<'d>(doc: &'d Document, name: &str) -> &'d TextData { + match &doc.nodes().find(|n| n.name == name).unwrap().kind { + NodeKind::Text(td) => td, + other => panic!("{name}: {other:?}"), + } +} + +fn red(fills: &Option>) -> bool { + fills.as_ref().is_some_and(|f| f.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))) +} + +#[test] +fn two_runs() { + let t = text("Hi", 0.0, 0.0, 200.0, 20.0, "Hello world", &[(0, 5, "HelveticaNeue-Bold", 16.0, RED), (5, 6, "HelveticaNeue", 12.0, BLACK)]); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Hi"); + assert_eq!(td.characters, "Hello world"); + let style_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + let (a, b) = (style_at(0), style_at(7)); + assert_eq!((a.font_family.as_str(), a.font_style.as_str(), a.font_size), ("Helvetica Neue", "Bold", 16.0)); + assert_eq!((b.font_family.as_str(), b.font_style.as_str(), b.font_size), ("Helvetica Neue", "Regular", 12.0)); + let node = doc.nodes().find(|n| n.name == "Hi").unwrap(); + assert!(red(&a.fills) || (red(&Some(node.fills.clone())) && !red(&b.fills))); +} + +#[test] +fn utf16_ranges() { + // "a😀b": the emoji takes two UTF-16 units. + let t = text( + "Emoji", + 0.0, + 0.0, + 200.0, + 20.0, + "a😀b", + &[(0, 1, "Helvetica", 10.0, BLACK), (1, 2, "Helvetica", 20.0, BLACK), (3, 1, "Helvetica", 30.0, BLACK)], + ); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Emoji"); + let size_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(td.style.font_size, |r| r.style.font_size); + assert_eq!((size_at(0), size_at(1), size_at(2)), (10.0, 20.0, 30.0)); + // A range splitting the emoji and one past the end: no panic, the text stays whole. + let t = text( + "Split", + 0.0, + 50.0, + 200.0, + 20.0, + "a😀b", + &[(0, 2, "Helvetica", 10.0, BLACK), (2, 99, "Helvetica", 20.0, BLACK), (500, 3, "Helvetica", 5.0, BLACK)], + ); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "Split").characters, "a😀b"); +} + +#[test] +fn paragraph_and_spacing() { + let mut t = text("Para", 0.0, 0.0, 200.0, 40.0, "Centered", &[(0, 8, "Helvetica", 14.0, BLACK)]); + let a = &mut t["attributedString"]["attributes"][0]["attributes"]; + a["paragraphStyle"] = + json!({"_class": "paragraphStyle", "alignment": 2, "maximumLineHeight": 24, "minimumLineHeight": 24, "paragraphSpacing": 8}); + a["kerning"] = json!(0.5); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Para"); + assert_eq!(td.align_h, TextAlignH::Center); + assert_eq!(td.style.line_height, LineHeight::Pixels(24.0)); + assert_eq!(td.paragraph_spacing, 8.0); + assert_eq!(td.style.letter_spacing, LetterSpacing::Pixels(0.5)); + for (code, want) in [(0, TextAlignH::Left), (1, TextAlignH::Right), (3, TextAlignH::Justified), (4, TextAlignH::Left)] { + let mut t = text("A", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]["alignment"] = json!(code); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "A").align_h, want, "alignment {code}"); + } +} + +#[test] +fn behaviour_and_vertical_alignment() { + let cases = [(0, TextAutoResize::WidthAndHeight), (1, TextAutoResize::Height), (2, TextAutoResize::None)]; + for (b, want) in cases { + let mut t = text("T", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["textBehaviour"] = json!(b); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "T").auto_resize, want, "behaviour {b}"); + } + for (v, want) in [(0, TextAlignV::Top), (1, TextAlignV::Middle), (2, TextAlignV::Bottom)] { + let mut t = text("V", 0.0, 0.0, 100.0, 60.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["textBehaviour"] = json!(2); + t["style"]["textStyle"] = json!({"_class": "textStyle", "verticalAlignment": v, "encodedAttributes": {}}); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "V").align_v, want, "vertical {v}"); + } +} + +#[test] +fn decorations_and_case() { + let mut t = text( + "Deco", + 0.0, + 0.0, + 200.0, + 20.0, + "abcdef", + &[(0, 2, "Helvetica", 14.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK), (4, 2, "Helvetica", 14.0, BLACK)], + ); + t["attributedString"]["attributes"][0]["attributes"]["underlineStyle"] = json!(1); + t["attributedString"]["attributes"][1]["attributes"]["strikethroughStyle"] = json!(1); + t["attributedString"]["attributes"][2]["attributes"]["MSAttributedStringTextTransformAttribute"] = json!(1); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Deco"); + let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + assert_eq!(at(0).decoration, TextDecoration::Underline); + assert_eq!(at(2).decoration, TextDecoration::Strikethrough); + assert_eq!(at(4).case, TextCase::Upper); +} + +#[test] +fn text_layer_fill_wins_over_run_colour() { + let t = styled(text("Filled", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)])); + let (doc, _) = open(vec![t]); + let n = doc.nodes().find(|n| n.name == "Filled").unwrap(); + assert!(red(&Some(n.fills.clone()))); +} + +#[test] +fn font_names() { + let cases = [ + ("HelveticaNeue-Bold", "Helvetica Neue", "Bold"), + ("HelveticaNeue", "Helvetica Neue", "Regular"), + ("HelveticaNeue-Medium", "Helvetica Neue", "Medium"), + ("PingFangSC-Semibold", "PingFang SC", "Semi Bold"), + ("PingFangTC-Regular", "PingFang TC", "Regular"), + (".SFNSText", "SF Pro Text", "Regular"), + (".SFNSDisplay-Bold", "SF Pro Display", "Bold"), + ("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"), + ("Impact", "Impact", "Regular"), + ("SFUIText-Semibold", "SF UI Text", "Semi Bold"), + ("SFUIDisplay-Regular", "SF UI Display", "Regular"), + ("SFCompactDisplay-Semibold", "SF Compact Display", "Semi Bold"), + ("SFProText-Bold", "SF Pro Text", "Bold"), + ("IBMPlexSans-MediumItalic", "IBM Plex Sans", "Medium Italic"), + ("Roboto-Italic", "Roboto", "Italic"), + ("", "Helvetica", "Regular"), + ]; + for (ps, family, style) in cases { + assert_eq!(font_name(ps), (family.to_string(), style.to_string()), "{ps}"); + } +} + +#[test] +fn long_and_odd_text_is_safe() { + let long = "x".repeat(10_000); + let t = text("Long", 0.0, 0.0, 100.0, 20.0, &long, &[(0, usize::MAX >> 1, "Helvetica", 14.0, BLACK)]); + let mut odd = text("Odd", 0.0, 0.0, 100.0, 20.0, "abc", &[(0, 3, "Helvetica", -5.0, BLACK)]); + odd["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"] = json!("junk"); + let mut empty = text("Empty", 0.0, 0.0, 100.0, 20.0, "", &[]); + empty["attributedString"]["string"] = json!(5); + let (doc, _) = open(vec![t, odd, empty]); + assert_eq!(data(&doc, "Long").characters.len(), 10_000); + assert!(data(&doc, "Odd").style.font_size > 0.0); +} + +#[test] +fn embedded_fonts_are_reported() { + let mut d = document(&["P"]); + d["fontReferences"] = json!([ + {"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Regular.otf", "fontData": {"_ref": "fonts/abc"}}, + {"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Bold.otf", "fontData": {"_ref": "fonts/def"}}, + {"_class": "fontReference", "fontFamilyName": "IBM Plex Mono", "fontData": {"_ref": "fonts/ghi"}}, + ]); + let z = sketch_zip("83.2", 139, d, &[page("P", "Page", vec![])], &[("fonts/abc", b"x")]); + let (_, warnings) = import(&z, "f").unwrap(); + let w = warnings.iter().find(|w| w.contains("embedded")).expect("a note about embedded fonts"); + assert!(w.contains("IBM Plex Mono") && w.contains("IBM Plex Sans"), "{w}"); +} + +/// Where Sketch puts a line's extra height: above the text (`lineSpacingBehaviour` 0), below it +/// (1) or around it (2, like FigCraft); the text layer moves by the difference. +#[test] +fn line_spacing_behaviour_moves_text() { + let mk = |name: &str, beh: i64| { + let mut t = text(name, 0.0, 100.0, 100.0, 30.0, "Hg", &[(0, 2, "Helvetica", 14.0, BLACK)]); + t["lineSpacingBehaviour"] = json!(beh); + let a = &mut t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]; + a["maximumLineHeight"] = json!(30); + a["minimumLineHeight"] = json!(30); + t + }; + let (doc, _) = open(vec![mk("B0", 0), mk("B1", 1), mk("B2", 2)]); + let y = |n: &str| doc.world_transform(doc.nodes().find(|x| x.name == n).unwrap().id).as_coeffs()[5]; + // Extra height: 30 − 14 × 1.2 = 13.2; half of it either way. + assert!((y("B2") - 100.0).abs() < 1e-6, "{}", y("B2")); + assert!((y("B0") - 106.6).abs() < 1e-6, "{}", y("B0")); + assert!((y("B1") - 93.4).abs() < 1e-6, "{}", y("B1")); +} + +/// A variable font's axes (Sketch keys them by the tag as a number: 2003265652 is `wght`). +#[test] +fn font_variations() { + let mut t = text("Wide", 0.0, 0.0, 200.0, 50.0, "Orbit", &[(0, 5, "Skia", 40.0, BLACK)]); + t["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"]["attributes"]["variation"] = + json!({"2003265652": 3, "2003072104": 0.65, "junk": 1, "1": "x"}); + let (doc, _) = open(vec![t]); + let v = &data(&doc, "Wide").style.variations; + assert_eq!(v.get("wght"), Some(&3.0)); + assert_eq!(v.get("wdth"), Some(&0.65)); + assert_eq!(v.len(), 2, "{v:?}"); +} + +/// Font references without font data are only the fonts the document uses: no note. +#[test] +fn font_references_without_data_are_no_embedded_fonts() { + let mut d = document(&["P"]); + d["fontReferences"] = + json!([{"_class": "fontReference", "fontFamilyName": "Skia", "fontFileName": "Skia.ttf", "postscriptNames": ["Skia-Regular"]}]); + let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(!warnings.iter().any(|w| w.contains("embedded")), "{warnings:?}"); +} + +/// With a font lookup, a PostScript name gives the installed family and style it belongs to (the +/// name alone can't always tell: "CourierNewPSMT" is Courier New Regular); without one, or for a +/// font that isn't installed, the family is read from the name. +#[test] +fn postscript_names_find_installed_fonts() { + let mk = |name: &str, ps: &str| text(name, 0.0, 0.0, 100.0, 20.0, "Hi", &[(0, 2, ps, 14.0, BLACK)]); + let z = sketch_zip("83.2", 139, document(&["P"]), &[page("P", "Page", vec![mk("A", "CourierNewPSMT"), mk("B", "BrandSans-Bold")])], &[]); + let lookup: crate::sketch::FontLookup = Box::new(|ps: &str| (ps == "CourierNewPSMT").then(|| ("Courier New".to_string(), "Regular".to_string()))); + let (doc, _) = crate::sketch::import_with_fonts(&z, "f", Some(lookup)).unwrap(); + assert_eq!((data(&doc, "A").style.font_family.as_str(), data(&doc, "A").style.font_style.as_str()), ("Courier New", "Regular")); + assert_eq!(data(&doc, "B").style.font_family, "Brand Sans"); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(data(&doc, "A").style.font_family, "Courier New PSMT"); +} + +/// A run without kerning, a fixed line height or font axes has none: it doesn't take the first +/// run's (the text's base style) as its own. +#[test] +fn later_runs_dont_inherit_the_first_runs_settings() { + let mut t = text("Mixed", 0.0, 0.0, 200.0, 40.0, "AaBb", &[(0, 2, "Skia", 20.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK)]); + let first = &mut t["attributedString"]["attributes"][0]["attributes"]; + first["kerning"] = json!(2); + first["paragraphStyle"]["minimumLineHeight"] = json!(24); + first["paragraphStyle"]["maximumLineHeight"] = json!(24); + first["MSAttributedStringFontAttribute"]["attributes"]["variation"] = json!({"2003265652": 3}); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Mixed"); + let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + assert_eq!(at(0).letter_spacing.resolve(20.0), 2.0); + let second = at(2); + assert_eq!(second.font_family, "Helvetica"); + assert_eq!(second.letter_spacing.resolve(14.0), 0.0, "{second:?}"); + assert_eq!(second.line_height, figcraft_doc::LineHeight::Auto, "{second:?}"); + assert!(second.variations.is_empty(), "{second:?}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs new file mode 100644 index 000000000..8bad4c6c7 --- /dev/null +++ b/crates/format/src/sketch/tree.rs @@ -0,0 +1,618 @@ +//! Sketch pages and layers → REST API file JSON nodes. + +use figcraft_doc::VectorNetwork; +use figcraft_doc::geom::{Affine, Rect}; +use serde_json::{Map as JsonMap, Value, json}; + +use super::Archive; + +/// Most distinct notes kept. +const MAX_NOTES: usize = 200; +/// Work units symbol expansion may spend on one file (one per JSON value copied, see +/// `symbols::weight`): about three times what the largest design kit checked spends (21M, with +/// 2 GB at its peak), well below what runs out of memory; less on 32-bit targets (the web), where +/// such a kit keeps its first instances and notes the rest. +#[cfg(not(target_pointer_width = "32"))] +const MAX_WORK: u64 = 60_000_000; +#[cfg(target_pointer_width = "32")] +const MAX_WORK: u64 = 15_000_000; +/// Deepest layer nesting followed (deeper layers are skipped with a warning). +const MAX_DEPTH: usize = 200; +/// Largest size and farthest position kept (the REST importer's own bounds). +const MAX_SIZE: f64 = 1e6; +const MAX_COORD: f64 = 1e7; + +/// The REST JSON of a Sketch file, with what the mapping noticed. +pub(crate) struct Map { + pub json: Value, + pub warnings: Vec, + /// Vector networks the JSON references by `vectorNetworkRef`. + pub networks: Vec>, + /// A text layer stored the pre-48 way was met: the file is refused. + pub too_old: bool, +} + +/// The mapping's state while walking the file. +pub(crate) struct Cx { + pub symbols: super::symbols::Symbols, + pub shared: super::shared::Shared, + /// Ids of the styles made from the shared styles. + pub style_defs: std::collections::BTreeSet, + /// Work spent expanding symbols, and the most the file may take (see `symbols::weight`). + pub work: u64, + pub max_work: u64, + /// Notes given so far (each once). + pub seen: std::collections::BTreeSet, + /// Inside a shape group: whether its shapes are filled (open ones then close) and whether the + /// group has a border. Outside one: `None` / false (a shape's own style decides). + pub operand_fill: Option, + pub operand_border: bool, + /// The file is older than format 110 (Sketch 52): Sketch scales a resized instance's layers + /// instead of following their pins. + pub legacy_resize: bool, + /// The symbols being expanded, outermost first (a symbol inside itself stops there). + pub chain: Vec, + pub warnings: Vec, + pub networks: Vec>, + pub too_old: bool, + /// Layers mapped so far, and the most the file may give. + pub nodes: usize, + pub max_nodes: usize, + /// Finds installed faces by PostScript name, and what it found (or didn't) so far. + pub fonts: Option, + pub font_cache: std::collections::BTreeMap>, +} + +impl Cx { + pub(crate) fn warn(&mut self, msg: impl Into) { + let msg = msg.into(); + if self.seen.len() > MAX_NOTES || !self.seen.insert(msg.clone()) { + return; + } + if self.seen.len() > MAX_NOTES { + self.warnings.push("more notes were left out".into()); + } else { + self.warnings.push(msg); + } + } + + /// Spend `n` units of the work budget; false (with a note, once) when it would run out. + pub(crate) fn spend(&mut self, n: u64) -> bool { + if self.work.saturating_add(n) > self.max_work { + self.work = self.max_work; + self.warn("the file's symbols expand to more than the import handles; the instances past that were imported as empty frames"); + return false; + } + self.work += n; + true + } + + /// Spend `n` units of the work budget on tinting; false (with a note, once) when it would run + /// out. + pub(crate) fn spend_on_tint(&mut self, n: u64) -> bool { + if self.work.saturating_add(n) > self.max_work { + self.warn("tints past what the import handles were skipped; those layers keep their own colours"); + return false; + } + self.work += n; + true + } + + pub(crate) fn work_left(&self) -> u64 { + self.max_work.saturating_sub(self.work) + } +} + +/// Charge the copy tinting layer `l` makes against the work budget: whether it may be tinted. +pub(crate) fn spend_on_tint(cx: &mut Cx, l: &Value) -> bool { + cx.spend_on_tint(super::symbols::weight(l, 0)) +} + +/// Map the pages of `archive` to a REST file JSON named `name`. +pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Option) -> Map { + let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); + let shared = super::shared::Shared::read(&archive.document); + let mut cx = Cx { + symbols, + shared, + style_defs: std::collections::BTreeSet::new(), + work: 0, + max_work: MAX_WORK, + seen: std::collections::BTreeSet::new(), + operand_fill: None, + operand_border: false, + legacy_resize: archive.version > 0 && archive.version < 110, + chain: Vec::new(), + warnings: Vec::new(), + networks: Vec::new(), + too_old: false, + nodes: 0, + max_nodes, + fonts, + font_cache: std::collections::BTreeMap::new(), + }; + let defs = super::shared::Shared::definitions(&mut cx); + cx.style_defs = defs.iter().filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_string)).collect(); + let mut canvases = Vec::new(); + for (id, page) in &archive.pages { + let mut node = JsonMap::new(); + node.insert("type".into(), json!("CANVAS")); + node.insert("id".into(), json!(id)); + node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); + let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true); + node.insert("children".into(), Value::Array(kids)); + let starts = super::proto::starting_points(page); + if !starts.is_empty() { + node.insert("flowStartingPoints".into(), Value::Array(starts)); + } + canvases.push(Value::Object(node)); + } + let foreign = std::mem::take(&mut cx.symbols.foreign); + if !foreign.is_empty() { + canvases.push(super::symbols::library_page(&mut cx, &foreign)); + let libraries = cx.symbols.libraries.join(", "); + cx.warn(format!("symbols from the libraries {libraries} became local components on the page Library symbols")); + } + let mut json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}, "styleDefinitions": defs}); + if let Some((collections, variables)) = cx.shared.variables() { + json["variableCollections"] = collections; + json["variables"] = variables; + } + Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } +} + +/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. `parent`: +/// the parent layer's transform to the page; `root`: the layer sits on the page itself. +pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + if depth > MAX_DEPTH { + cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); + return None; + } + if is_archived_text(l) { + cx.too_old = true; + return None; + } + // A rectangle with angled or inside corners is a path of that outline. + let outlined; + let l = match super::path::cut_corners(l) { + Some(p) => { + outlined = p; + &outlined + } + None => l, + }; + let class = str_field(l, "_class")?; + if class == "shapeGroup" { + return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); + } + if class == "symbolMaster" { + return count(cx).then(|| super::symbols::component(cx, l, parent, depth, root)).flatten(); + } + if class == "symbolInstance" { + return count(cx).then(|| super::symbols::instance(cx, l, parent, depth, root)).flatten(); + } + let ty = match class { + "artboard" | "frame" => "FRAME", + // Sketch 2025 and newer: a group that behaves as a frame (1) or a graphic (2), or clips. + "group" if is_frame(l) => "FRAME", + "group" | "graphic" => "GROUP", + // Shapes whose points were moved are paths. + "rectangle" | "oval" if bool_field(l, "edited") == Some(true) => "VECTOR", + "rectangle" | "bitmap" => "RECTANGLE", + "oval" => "ELLIPSE", + "slice" => "SLICE", + "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", + "text" => "TEXT", + "MSImmutableHotspotLayer" | "hotspot" => "FRAME", + other => { + cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); + return None; + } + }; + if !count(cx) { + return None; + } + let (w, h) = size(l); + let mut world = parent * local(l, w, h); + if ty == "TEXT" { + world *= Affine::translate((0.0, super::text::baseline_shift(l))); + } + let mut node = base(l, ty, parent, world, w, h, root); + if ty == "TEXT" { + super::text::node(cx, l, &mut node); + } + if ty == "VECTOR" { + let shape = super::path::shape(cx, l, w, h)?; + super::path::put_shape(cx, shape, &mut node); + } + mask(l, &mut node); + match class { + "artboard" | "frame" => { + let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; + node.insert("clipsContent".into(), json!(clips)); + let background = (bool_field(l, "hasBackgroundColor") == Some(true)).then(|| l.get("backgroundColor").and_then(color)).flatten(); + if class == "artboard" || background.is_some() { + let c = background.unwrap_or_else(|| json!({"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0})); + node.insert("fills".into(), json!([{"type": "SOLID", "color": c}])); + } + } + "group" if ty == "FRAME" => { + // `clippingBehavior`: 0 the default (frames clip), 1 clip, 2 don't. + node.insert("clipsContent".into(), json!(l.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + } + "rectangle" => corner_radii(l, &mut node), + "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { + Some(r) => { + let mut paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + if let Some(f) = super::style::color_controls(cx, l.get("style")) { + paint["filters"] = f; + } + node.insert("fills".into(), json!([paint])); + } + None => cx.warn("images stored inside the JSON were skipped"), + }, + _ => {} + } + styles(cx, l, w, h, &mut node); + extras(cx, l, h, &mut node); + style_corners(l, &mut node); + if class == "MSImmutableHotspotLayer" || class == "hotspot" { + node.insert("clipsContent".into(), json!(false)); + node.remove("fills"); + } + // A group's or graphic's fill tints its layers (a frame's is its background). + let tinted; + let gb = l.get("groupBehavior").and_then(Value::as_i64); + let tint = if class == "group" { super::style::tint_of(l.get("style"), gb != Some(1)) } else { None }; + let l = match tint.filter(|_| spend_on_tint(cx, l)) { + Some(t) => { + // A plain group's legacy fill is all tint; a frame keeps its background fills. + if gb != Some(1) { + node.remove("fills"); + } + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + tinted = c; + &tinted + } + _ => l, + }; + if matches!(ty, "FRAME" | "GROUP") { + let kids = children(cx, l, world, (w, h), depth, false); + node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); + } + Some(fade(cx, l, node, world, w, h)) +} + +/// Layer `node` with a progressive opacity (Fade: the style's `contextSettings` with +/// `isProgressive` and a gradient whose stops' opacity is the layer's) as a group in its place +/// holding an alpha mask of that gradient over it; FigCraft has no progressive opacity. Other +/// layers as they are. +fn fade(cx: &mut Cx, l: &Value, node: JsonMap, world: Affine, w: f64, h: f64) -> Value { + let ctx = l.get("style").and_then(|s| s.get("contextSettings")); + let gradient = ctx.filter(|c| c.get("isProgressive").and_then(Value::as_bool) == Some(true)).and_then(|c| c.get("gradient")); + // A mask stays as it is: its group would stop masking the layers above it. + let Some(gradient) = gradient.filter(|_| node.get("isMask").and_then(Value::as_bool) != Some(true)) else { return Value::Object(node) }; + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "gradient": gradient}); + let fills = super::style::fills(cx, &json!({"fills": [fill]}), w, h); + if fills.is_empty() { + return Value::Object(node); + } + let id = str_field(l, "do_objectID").unwrap_or("layer"); + let identity = json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + let abs = json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}); + // The group takes the layer's id (stacks, links and overrides find it), the layer a new one. + let mut inner = node; + let mut group = JsonMap::new(); + for k in ["relativeTransform", "absoluteBoundingBox", "constraints", "visible", "locked"] { + if let Some(v) = inner.get(k) { + group.insert(k.into(), v.clone()); + } + } + group.insert("type".into(), json!("GROUP")); + group.insert("id".into(), json!(id)); + group.insert("name".into(), inner.get("name").cloned().unwrap_or_else(|| json!("Fade"))); + group.insert("size".into(), json!({"x": w, "y": h})); + group.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("id".into(), json!(format!("{id}-faded"))); + inner.insert("relativeTransform".into(), identity.clone()); + inner.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("visible".into(), json!(true)); + let mask = json!({"type": "RECTANGLE", "id": format!("{id}-fade-mask"), "name": "Fade", "visible": true, "size": {"x": w, "y": h}, + "relativeTransform": identity, "absoluteBoundingBox": abs, "fills": fills, "isMask": true, "maskType": "ALPHA"}); + group.insert("children".into(), json!([mask, Value::Object(inner)])); + Value::Object(group) +} + +/// A Sketch 2025+ group that is a frame: it behaves as a frame or a graphic, or clips. +pub(crate) fn is_frame(l: &Value) -> bool { + matches!(l.get("groupBehavior").and_then(Value::as_i64), Some(1 | 2)) || l.get("clippingBehavior").and_then(Value::as_i64) == Some(1) +} + +/// Corners kept in the style (Sketch 2025+: one radius for all, or one per corner from the top +/// left, clockwise) on shapes and frames that have corners; smooth corners keep their smoothing. +pub(crate) fn style_corners(l: &Value, node: &mut JsonMap) { + let Some(c) = l.get("style").and_then(|s| s.get("corners")) else { return }; + if !matches!(node.get("type").and_then(Value::as_str), Some("RECTANGLE" | "FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let radii: Vec = c + .get("radii") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|r| r.is_finite()) + .map(|r| r.clamp(0.0, MAX_SIZE)) + .take(4) + .collect(); + match radii.as_slice() { + [] => return, + [r] => { + node.insert("cornerRadius".into(), json!(r)); + node.remove("rectangleCornerRadii"); + } + more => { + // Values repeat to fill the four corners. + let all: Vec = (0..4).map(|i| more.get(i % more.len()).copied().unwrap_or(0.0)).collect(); + node.insert("rectangleCornerRadii".into(), json!(all)); + } + } + // Style 1 is smooth: its smoothing applies. + if c.get("style").and_then(Value::as_i64) == Some(1) + && let Some(sm) = c.get("smoothing").and_then(Value::as_f64).filter(|v| v.is_finite()) + { + node.insert("cornerSmoothing".into(), json!(sm.clamp(0.0, 1.0))); + } +} + +/// Layer `l`'s style into `node`, and its links to the styles made from its shared style. +pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { + // An artboard shows its background colour only; Sketch doesn't draw a style it carries. + if str_field(l, "_class") == Some("artboard") { + return; + } + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, node); + } + cx.shared.link(&cx.style_defs, l, node); +} + +/// Layer `l`'s prototype link, export settings, layout grids (frames) and notes. +pub(crate) fn extras(cx: &mut Cx, l: &Value, h: f64, node: &mut JsonMap) { + super::proto::interactions(l, node); + super::proto::export_settings(cx, l, node); + if matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + super::proto::layout_grids(l, h, node); + } + super::proto::notes(cx, l); +} + +/// A clipping mask's flag and kind (outline or alpha). +pub(crate) fn mask(l: &Value, node: &mut JsonMap) { + if bool_field(l, "hasClippingMask") == Some(true) { + node.insert("isMask".into(), json!(true)); + node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); + } +} + +/// Count one more layer; false (with a warning) once the file's budget is spent. +fn count(cx: &mut Cx) -> bool { + if cx.nodes >= cx.max_nodes { + cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + return false; + } + cx.nodes += 1; + true +} + +/// The properties every REST node of layer `l` has: its type, id, name, visibility, lock, size, +/// place (`world`, relative to `parent`) and constraints. +pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h: f64, root: bool) -> JsonMap { + let mut node = JsonMap::new(); + node.insert("type".into(), json!(ty)); + if let Some(id) = str_field(l, "do_objectID") { + node.insert("id".into(), json!(id)); + } + let class = str_field(l, "_class").unwrap_or("Layer"); + node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); + node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); + node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); + node.insert("size".into(), json!({"x": w, "y": h})); + let [a, b, c, d, e, f] = (parent.inverse() * world).as_coeffs(); + node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); + // The page places top-level layers by their transform; deeper ones also say where they end up, + // which tells the importer their transform is relative to their group. + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + node.insert("constraints".into(), constraints(pin_mask(l))); + node +} + +/// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]). +pub(crate) fn size(l: &Value) -> (f64, f64) { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE); + (n("width"), n("height")) +} + +/// A layer's transform within its parent: its frame's position, then its rotation (degrees, +/// counter-clockwise on screen) and flips about its centre. Sketch flips on screen, after rotating +/// (a quarter-turned layer flipped left to right mirrors its own up and down). +pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); + let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0; + let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 }; + let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 }; + Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0)) + * Affine::scale_non_uniform(sx, sy) + * Affine::rotate(-rot.to_radians()) + * Affine::translate((-w / 2.0, -h / 2.0)) +} + +/// A layer's pins as a `resizingConstraint` mask. Sketch 2025 and newer keep them in +/// `horizontalPins`/`verticalPins` (bits: 1 the start, 4 the end) and `horizontalSizing`/ +/// `verticalSizing` (fixed and fit keep the size; fill and relative don't), with +/// `resizingConstraint` left at 63. +pub(crate) fn pin_mask(l: &Value) -> i64 { + if l.get("horizontalPins").is_none() && l.get("horizontalSizing").is_none() { + return l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63); + } + let int = |k: &str| l.get(k).and_then(Value::as_i64).unwrap_or(0); + let fixed = |k: &str| matches!(int(k), 0 | 1); + let mut mask = 63; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + for (on, bit) in [ + (int("horizontalPins") & 1 != 0, 4), + (int("horizontalPins") & 4 != 0, 1), + (fixed("horizontalSizing"), 2), + (int("verticalPins") & 1 != 0, 32), + (int("verticalPins") & 4 != 0, 8), + (fixed("verticalSizing"), 16), + ] { + if on { + mask &= !bit; + } + } + mask +} + +/// REST constraints from Sketch's `resizingConstraint`: six bits, a cleared bit pins that side or +/// fixes that size (right 1, width 2, left 4, bottom 8, height 16, top 32). A layer pinned on +/// neither side keeps its place in proportion (scale) unless its size is fixed (centre). +fn constraints(mask: i64) -> Value { + let pinned = |bit: i64| mask & bit == 0; + let axis = |low: i64, size: i64, high: i64, low_name: &str, high_name: &str, both: &str| match (pinned(low), pinned(high)) { + // Both sides and the size pinned can't all hold: Sketch scales such a layer. + (true, true) if pinned(size) => "SCALE".into(), + (true, true) => both.to_string(), + (true, false) => low_name.to_string(), + (false, true) => high_name.to_string(), + (false, false) if pinned(size) => "CENTER".into(), + (false, false) => "SCALE".into(), + }; + json!({ + "horizontal": axis(4, 2, 1, "LEFT", "RIGHT", "LEFT_RIGHT"), + "vertical": axis(32, 16, 8, "TOP", "BOTTOM", "TOP_BOTTOM"), + }) +} + +/// A rectangle's corner radii: per corner from its points (top left, top right, bottom right, +/// bottom left), else its `fixedRadius`. +fn corner_radii(l: &Value, node: &mut JsonMap) { + let radius = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, MAX_SIZE)); + let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let each: Vec = pts.iter().map(|p| radius(p.get("cornerRadius"))).collect(); + if each.len() == 4 && each.iter().any(|r| *r > 0.0) { + node.insert("rectangleCornerRadii".into(), json!(each)); + } else if radius(l.get("fixedRadius")) > 0.0 { + node.insert("cornerRadius".into(), json!(radius(l.get("fixedRadius")))); + } +} + +/// A Sketch colour (`red`, `green`, `blue`, `alpha` in 0–1) as a REST colour. +pub(crate) fn color(c: &Value) -> Option { + let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); + Some(json!({"r": n("red")?, "g": n("green")?, "b": n("blue")?, "a": n("alpha").unwrap_or(1.0)})) +} + +pub(crate) fn bool_field(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_bool) +} + +/// At most 64 characters of a name read from the file, for warnings. +fn clip(s: &str) -> String { + s.chars().take(64).collect() +} + +/// The child layers of `l` (placed at `world`, `size`) as REST nodes. A clipping mask clips the +/// layers after it up to one that breaks the chain (or the next mask); when that happens before +/// the end, the mask and what it clips are wrapped in a group of their own, since a FigCraft mask +/// clips everything above it. +pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), depth: usize, root: bool) -> Vec { + let built: Vec<(bool, Value)> = layers(l) + .iter() + .filter_map(|c| { + let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true); + layer(cx, c, world, depth + 1, root).map(|n| (breaks, n)) + }) + .flat_map(|(breaks, n)| match drawn_mask(&n) { + // Sketch also draws a mask with its own style, under what it clips; a FigCraft mask + // only clips, so a plain copy of it goes underneath. + // The copy ends an earlier mask's chain as the mask itself would. + Some(copy) => vec![(breaks, copy), (breaks, n)], + None => vec![(breaks, n)], + }) + .collect(); + let mut out = Vec::new(); + let mut i = 0; + while let Some((_, n)) = built.get(i) { + let end = if n.get("isMask").and_then(Value::as_bool) == Some(true) { + built.iter().skip(i + 1).position(|(b, _)| *b).map(|k| i + 1 + k) + } else { + None + }; + let Some(end) = end else { + out.push(n.clone()); + i += 1; + continue; + }; + let masked: Vec = built.get(i..end).unwrap_or_default().iter().map(|(_, n)| n.clone()).collect(); + let mut g = JsonMap::new(); + g.insert("type".into(), json!("GROUP")); + g.insert("name".into(), json!("Mask group")); + g.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])); + g.insert("size".into(), json!({"x": size.0, "y": size.1})); + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1)); + g.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + g.insert("children".into(), Value::Array(masked)); + out.push(Value::Object(g)); + i = end; + } + out +} + +/// A plain copy of outline mask `n` when its style shows something (fills, borders or effects). +/// An alpha mask only masks. +fn drawn_mask(n: &Value) -> Option { + if n.get("isMask").and_then(Value::as_bool) != Some(true) || n.get("maskType").and_then(Value::as_str) == Some("ALPHA") { + return None; + } + let shows = + |key: &str| n.get(key).and_then(Value::as_array).is_some_and(|l| l.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); + if !(shows("fills") || shows("strokes") || shows("effects")) { + return None; + } + let mut copy = n.clone(); + let m = copy.as_object_mut()?; + m.remove("isMask"); + m.remove("maskType"); + m.remove("id"); + let name = m.get("name").and_then(Value::as_str).unwrap_or("Mask").to_string(); + m.insert("name".into(), json!(format!("{name} (drawn)"))); + Some(copy) +} + +/// A text layer whose string is a binary property list (Sketch 43–47). +fn is_archived_text(l: &Value) -> bool { + l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some()) +} + +/// The child layers of a page or layer. +pub(crate) fn layers(l: &Value) -> &[Value] { + l.get("layers").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() +} + +pub(crate) fn str_field<'v>(v: &'v Value, key: &str) -> Option<&'v str> { + v.get(key).and_then(Value::as_str) +} diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs new file mode 100644 index 000000000..5399a3df5 --- /dev/null +++ b/crates/format/src/sketch/tree_tests.rs @@ -0,0 +1,274 @@ +//! Sketch layers → document layers: kinds, positions, transforms, constraints, visibility. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, HConstraint, NodeKind, VConstraint}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +/// Import one page holding `layers`, with extra archive `files`. +fn open_with(layers: Vec, files: &[(&str, &[u8])]) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], files); + import(&z, "f").unwrap() +} + +fn open(layers: Vec) -> (Document, Vec) { + open_with(layers, &[]) +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn solid(doc: &Document, name: &str) -> Option<[f32; 4]> { + node(doc, name).fills.iter().find_map(|p| match &p.kind { + PaintKind::Solid { color } => Some([color.r, color.g, color.b, p.opacity]), + _ => None, + }) +} + +#[test] +fn artboard_is_frame_with_white_fill_unless_background() { + let plain = artboard("A", "Plain", 0.0, 0.0, 100.0, 50.0, vec![]); + let red = with(artboard("B", "Red", 200.0, 0.0, 100.0, 50.0, vec![]), "hasBackgroundColor", json!(true)); + let red = with(red, "backgroundColor", json!({"_class": "color", "red": 1, "green": 0, "blue": 0, "alpha": 1})); + let (doc, _) = open(vec![plain, red]); + assert!(matches!(node(&doc, "Plain").kind, NodeKind::Frame)); + assert!(node(&doc, "Plain").clip); + assert_eq!(solid(&doc, "Plain"), Some([1.0, 1.0, 1.0, 1.0])); + assert_eq!(solid(&doc, "Red"), Some([1.0, 0.0, 0.0, 1.0])); + let b = doc.world_bounds(node(&doc, "Red").id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (200.0, 0.0, 100.0, 50.0)); +} + +#[test] +fn group_children_positions_accumulate() { + let r = rect("R", 5.0, 5.0, 10.0, 10.0); + let g = group("G", 10.0, 20.0, 15.0, 15.0, vec![r]); + let (doc, _) = open(vec![artboard("A", "Board", 100.0, 100.0, 300.0, 300.0, vec![g])]); + assert!(matches!(node(&doc, "G").kind, NodeKind::Group)); + let b = doc.world_bounds(node(&doc, "R").id).unwrap(); + assert!((b.x0 - 115.0).abs() < 1e-6 && (b.y0 - 125.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn rotation_and_flip() { + let rotated = with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)); + let flipped = with(rect("Flipped", 100.0, 0.0, 40.0, 20.0), "isFlippedHorizontal", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![rotated, flipped])]); + let b = doc.world_bounds(node(&doc, "Turned").id).unwrap(); + assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + assert!((b.center().x - 20.0).abs() < 1e-6 && (b.center().y - 10.0).abs() < 1e-6, "{b:?}"); + // Counter-clockwise on screen: the box's x axis points up. + let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs(); + assert!(t[1] < -0.99, "{t:?}"); + let t = doc.world_transform(node(&doc, "Flipped").id).as_coeffs(); + assert!(t[0] < -0.99, "{t:?}"); + let b = doc.world_bounds(node(&doc, "Flipped").id).unwrap(); + assert!((b.x0 - 100.0).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn resizing_constraint_bits() { + use HConstraint as H; + use VConstraint as V; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + let cases = [ + (63, H::Scale, V::Scale), + (63 - 4 - 32, H::Left, V::Top), + (63 - 1 - 8, H::Right, V::Bottom), + (63 - 1 - 4, H::LeftRight, V::Scale), + (63 - 8 - 32, H::Scale, V::TopBottom), + (63 - 2 - 16, H::Center, V::Center), + (63 - 1 - 2, H::Right, V::Scale), + // Everything pinned is over-constrained: Sketch scales it. + (0, H::Scale, V::Scale), + ]; + let layers: Vec = cases + .iter() + .enumerate() + .map(|(i, (mask, _, _))| with(rect(&format!("R{i}"), 0.0, 0.0, 10.0, 10.0), "resizingConstraint", json!(mask))) + .collect(); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]); + for (i, (mask, h, v)) in cases.iter().enumerate() { + let c = node(&doc, &format!("R{i}")).constraints; + assert_eq!((c.horizontal, c.vertical), (*h, *v), "mask {mask}"); + } +} + +#[test] +fn hidden_locked_named() { + let r = with(with(rect("Hidden", 0.0, 0.0, 10.0, 10.0), "isVisible", json!(false)), "isLocked", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]); + let n = node(&doc, "Hidden"); + assert!(!n.visible && n.locked); +} + +#[test] +fn bad_numbers_are_sane() { + let mut nan = rect("Junk", 0.0, 0.0, 10.0, 10.0); + nan["frame"]["width"] = json!("NaN"); + nan["frame"]["x"] = json!(1e300); + let mut neg = rect("Negative", 0.0, 0.0, 10.0, 10.0); + neg["frame"]["height"] = json!(-5); + neg["rotation"] = json!("x"); + let mut none = rect("Frameless", 0.0, 0.0, 10.0, 10.0); + none.as_object_mut().unwrap().remove("frame"); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![nan, neg, none])]); + for n in doc.nodes() { + assert!(n.width.is_finite() && n.height.is_finite() && n.width >= 0.0 && n.height >= 0.0, "{}", n.name); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{}", n.name); + } +} + +#[test] +fn rectangle_corner_radii() { + let mut each = rect("Each", 0.0, 0.0, 40.0, 40.0); + for (i, r) in [1, 2, 3, 4].iter().enumerate() { + each["points"][i]["cornerRadius"] = json!(r); + } + let fixed = with(rect("Fixed", 50.0, 0.0, 40.0, 40.0), "fixedRadius", json!(8)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![each, fixed])]); + assert!(matches!(node(&doc, "Each").kind, NodeKind::Rectangle)); + assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(node(&doc, "Fixed").corners.radii, [8.0; 4]); +} + +#[test] +fn oval_slice_and_bitmap() { + let mut oval = rect("Round", 0.0, 0.0, 30.0, 20.0); + oval["_class"] = json!("oval"); + let mut slice = rect("Cut", 0.0, 0.0, 30.0, 20.0); + slice["_class"] = json!("slice"); + let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/abc123.png"}); + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![oval, slice, bitmap])], &[("images/abc123.png", &png)]); + assert!(matches!(node(&doc, "Round").kind, NodeKind::Ellipse { .. })); + assert!(matches!(node(&doc, "Cut").kind, NodeKind::Slice)); + let photo = node(&doc, "Photo"); + assert!(matches!(photo.kind, NodeKind::Rectangle)); + assert!(photo.fills.iter().any(|p| matches!(&p.kind, PaintKind::Image(_)))); + assert_eq!(doc.images.len(), 1); +} + +#[test] +fn unknown_class_is_a_warning() { + let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0); + odd["_class"] = json!("MSSomethingNew"); + let (doc, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![odd])]); + assert!(doc.nodes().all(|n| n.name != "Odd")); + assert!(warnings.iter().any(|w| w.contains("MSSomethingNew")), "{warnings:?}"); +} + +#[test] +fn sketch_2025_frames_and_graphics() { + let mut frame = artboard("F", "Frame", 0.0, 0.0, 100.0, 100.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]); + frame["_class"] = json!("frame"); + frame["clipsContent"] = json!(false); + let mut graphic = group("Graphic", 10.0, 10.0, 10.0, 10.0, vec![rect("Leaf", 0.0, 0.0, 10.0, 10.0)]); + graphic["_class"] = json!("graphic"); + frame["layers"].as_array_mut().unwrap().push(graphic); + let (doc, _) = open(vec![frame]); + assert!(matches!(node(&doc, "Frame").kind, NodeKind::Frame)); + assert!(!node(&doc, "Frame").clip); + assert!(matches!(node(&doc, "Graphic").kind, NodeKind::Group)); +} + +#[test] +fn many_distinct_warnings_are_capped() { + let layers: Vec = (0..5000) + .map(|i| { + let mut l = rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0); + l["_class"] = json!(format!("MSOdd{i}")); + l + }) + .collect(); + let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]); + assert!(warnings.len() <= 250, "{}", warnings.len()); +} + +#[test] +fn bomb_pages_are_skipped() { + // 64 MB of `[0,0,…]` packs into a few hundred kilobytes. + let bomb = format!("{{\"_class\":\"page\",\"do_objectID\":\"B\",\"name\":\"Bomb\",\"layers\":[],\"junk\":[{}0]}}", "0,".repeat(32 * 1024 * 1024)); + let mut out = Vec::new(); + { + use std::io::Write; + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("document.json", o).unwrap(); + z.write_all(document(&["B", "P"]).to_string().as_bytes()).unwrap(); + z.start_file("meta.json", o).unwrap(); + z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": "50.2", "version": 103}).to_string().as_bytes()).unwrap(); + z.start_file("pages/B.json", o).unwrap(); + z.write_all(bomb.as_bytes()).unwrap(); + z.start_file("pages/P.json", o).unwrap(); + z.write_all(page("P", "Fine", vec![]).to_string().as_bytes()).unwrap(); + z.finish().unwrap(); + } + let (doc, warnings) = import(&out, "f").unwrap(); + let names: Vec = doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect(); + assert!(!names.contains(&"Bomb".to_string()) && names.contains(&"Fine".to_string()), "{names:?}"); + assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}"); +} + +#[test] +fn deep_document_json_still_opens_the_pages() { + let mut deep = json!({"_class": "symbolMaster"}); + for _ in 0..150 { + deep = json!({"_class": "group", "layers": [deep]}); + } + let mut d = document(&["P"]); + d["foreignSymbols"] = json!([{"symbolMaster": deep}]); + let z = sketch_zip("50.2", 103, d, &[page("P", "Kept", vec![])], &[]); + let (doc, warnings) = import(&z, "f").unwrap(); + assert!(doc.pages.iter().any(|p| doc.node(*p).unwrap().name == "Kept")); + assert!(!warnings.is_empty()); +} + +/// Sketch flips a rotated layer on screen, after rotating it. +#[test] +fn flip_applies_after_rotation() { + let r = with(with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)), "isFlippedHorizontal", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![r])]); + let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs(); + // Rotated a quarter turn counter-clockwise (x → up, y → right), then mirrored left to right. + assert!(t[0].abs() < 1e-9 && (t[1] + 1.0).abs() < 1e-9 && (t[2] + 1.0).abs() < 1e-9 && t[3].abs() < 1e-9, "{t:?}"); +} + +#[test] +fn edited_shapes_keep_their_points() { + let mut drop = rect("Drop", 0.0, 0.0, 20.0, 30.0); + drop["_class"] = json!("oval"); + drop["edited"] = json!(true); + drop["points"] = json!([point(0.5, 0.0), point(1.0, 0.7), point(0.5, 1.0), point(0.0, 0.7)]); + let mut box_ = rect("Slanted", 50.0, 0.0, 20.0, 20.0); + box_["edited"] = json!(true); + box_["points"][1]["point"] = json!("{1, 0.3}"); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![drop, box_])]); + let NodeKind::Vector { network } = &node(&doc, "Drop").kind else { panic!("not a vector") }; + assert_eq!((network.vertices[0].x, network.vertices[0].y), (10.0, 0.0)); + assert!(matches!(node(&doc, "Slanted").kind, NodeKind::Vector { .. })); +} + +/// Sketch draws an artboard's background colour only; a style it may carry (borders, shadows) isn't +/// drawn. Sketch 2025 frames do draw theirs. +#[test] +fn artboards_ignore_their_style() { + let border = json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": {"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1}, "position": 2, "thickness": 1}]); + let mut a = artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![]); + a["style"]["borders"] = border.clone(); + let mut f = artboard("F", "Frame", 200.0, 0.0, 100.0, 100.0, vec![]); + f["_class"] = json!("frame"); + f["style"]["borders"] = border; + let (doc, _) = open(vec![a, f]); + assert!(node(&doc, "Board").strokes.is_empty()); + assert_eq!(node(&doc, "Frame").strokes.len(), 1); +} diff --git a/crates/format/src/thumbnail.rs b/crates/format/src/thumbnail.rs index 76ebcf4e3..718e8d791 100644 --- a/crates/format/src/thumbnail.rs +++ b/crates/format/src/thumbnail.rs @@ -1,5 +1,5 @@ -//! The picture a saved file carries of itself: `thumbnail.png` inside a `.figcraft` or `.fig` zip -//! (the home screen's recent files show it), and the colour of the page it sits on. Image files are +//! The picture a saved file carries of itself: `thumbnail.png` inside a `.figcraft` or `.fig` zip, +//! `previews/preview.png` inside a `.sketch` zip (the home screen's recent files show it), and the colour of the page it sits on. Image files are //! their own picture. Everything is read within caps: a missing, damaged or oversized picture gives //! `None`, never an error or a panic. @@ -33,6 +33,12 @@ pub fn thumbnail_png(archive: R) -> Option> { crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok() } +/// The bytes of `previews/preview.png` in a `.sketch` zip; `None` as [`thumbnail_png`] gives it. +pub fn sketch_preview_png(archive: R) -> Option> { + let mut z = zip::ZipArchive::new(archive).ok()?; + crate::read_member(&mut z, "previews/preview.png", MAX_THUMBNAIL_BYTES).ok() +} + /// Decode a picture (PNG, JPEG, WebP, GIF, BMP or TIFF) no larger than [`MAX_THUMBNAIL_SIDE`] on /// either side, scaled down to at most `fit` pixels on its longer side (never up). pub fn decode_thumbnail(bytes: &[u8], fit: u32) -> Option { @@ -59,6 +65,8 @@ pub enum FileKind { FigCraft, /// `.fig` Fig, + /// `.sketch` + Sketch, /// `.svg` Svg, /// A raster image (`.png`, `.jpg`, `.webp`, …). @@ -72,6 +80,7 @@ impl FileKind { match ext.as_str() { "figcraft" => FileKind::FigCraft, "fig" => FileKind::Fig, + "sketch" => FileKind::Sketch, "svg" => FileKind::Svg, "png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" => FileKind::Image, _ => FileKind::Other, @@ -80,7 +89,7 @@ impl FileKind { } /// The picture of the file at `path`, at most `fit` pixels on its longer side: the thumbnail a -/// `.figcraft` or `.fig` file carries, or an image file itself (up to [`MAX_IMAGE_FILE_BYTES`]). +/// `.figcraft`, `.fig` or `.sketch` file carries, or an image file itself (up to [`MAX_IMAGE_FILE_BYTES`]). /// `None` for anything else, and for a file that is missing, unreadable or damaged. pub fn file_thumbnail(path: &Path, fit: u32) -> Option { match FileKind::of(path) { @@ -89,6 +98,11 @@ pub fn file_thumbnail(path: &Path, fit: u32) -> Option { let png = thumbnail_png(std::io::BufReader::new(file))?; decode_thumbnail(&png, fit) } + FileKind::Sketch => { + let file = std::fs::File::open(path).ok()?; + let png = sketch_preview_png(std::io::BufReader::new(file))?; + decode_thumbnail(&png, fit) + } FileKind::Image => { let file = std::fs::File::open(path).ok()?; let mut bytes = Vec::new(); @@ -109,7 +123,7 @@ pub fn file_thumbnail(path: &Path, fit: u32) -> Option { pub struct Preview { pub page: Option<[f32; 4]>, pub picture: Option, - /// A design file or a board (`.fig` files and images: design). + /// A design file or a board (`.fig` and `.sketch` files and images: design). pub kind: figcraft_doc::FileKind, } @@ -121,7 +135,8 @@ pub fn file_preview(path: &Path, fit: u32) -> Preview { let Ok(file) = std::fs::File::open(path) else { return Preview::default() }; archive_preview(std::io::BufReader::new(file), fit) } - FileKind::Image => Preview { picture: file_thumbnail(path, fit), ..Preview::default() }, + // A Sketch file's page colour isn't read: the preview is its picture alone, as for an image. + FileKind::Sketch | FileKind::Image => Preview { picture: file_thumbnail(path, fit), ..Preview::default() }, FileKind::Svg | FileKind::Other => Preview::default(), } } diff --git a/crates/format/src/thumbnail_tests.rs b/crates/format/src/thumbnail_tests.rs index eeaeaba59..1be565ab9 100644 --- a/crates/format/src/thumbnail_tests.rs +++ b/crates/format/src/thumbnail_tests.rs @@ -117,9 +117,14 @@ fn files_on_disk_by_kind() { let folder = d.join("folder.figcraft"); std::fs::create_dir_all(&folder).unwrap(); assert_eq!(file_thumbnail(&folder, 640), None); - let kinds: Vec = - ["a.figcraft", "a.FIG", "a.svg", "a.jpeg", "a.webp", "a.txt", "noext"].iter().map(|p| FileKind::of(std::path::Path::new(p))).collect(); - assert_eq!(kinds, [FileKind::FigCraft, FileKind::Fig, FileKind::Svg, FileKind::Image, FileKind::Image, FileKind::Other, FileKind::Other]); + let kinds: Vec = ["a.figcraft", "a.FIG", "a.Sketch", "a.svg", "a.jpeg", "a.webp", "a.txt", "noext"] + .iter() + .map(|p| FileKind::of(std::path::Path::new(p))) + .collect(); + assert_eq!( + kinds, + [FileKind::FigCraft, FileKind::Fig, FileKind::Sketch, FileKind::Svg, FileKind::Image, FileKind::Image, FileKind::Other, FileKind::Other] + ); } /// A document whose first page is `rgb`. @@ -181,3 +186,21 @@ fn previews_carry_the_first_pages_colour() { let p = file_preview(&image, 640); assert!(p.page.is_none() && p.picture.is_some()); } + +#[test] +fn sketch_files_show_their_preview_png() { + use crate::thumbnail::{Preview, file_preview}; + let d = dir("sketch"); + let sketch = d.join("screens.sketch"); + std::fs::write(&sketch, zip(&[("document.json", b"{}"), ("previews/preview.png", &png(8, 4))])).unwrap(); + assert_eq!(file_thumbnail(&sketch, 640).map(|t| (t.width, t.height)), Some((8, 4))); + let p = file_preview(&sketch, 640); + assert_eq!((p.page, p.picture.map(|t| (t.width, t.height)), p.kind), (None, Some((8, 4)), figcraft_doc::FileKind::Design)); + // Only `previews/preview.png` counts; a damaged file has no picture. + let other = d.join("other.sketch"); + std::fs::write(&other, zip(&[("thumbnail.png", &png(8, 4))])).unwrap(); + assert_eq!(file_thumbnail(&other, 640), None); + let junk = d.join("junk.sketch"); + std::fs::write(&junk, [0xFFu8; 64]).unwrap(); + assert_eq!(file_preview(&junk, 640), Preview::default()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 876a29e53..b4cf0794d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -253,7 +253,7 @@ pub fn tool_definitions() -> Vec { tool( "open_file", "Open file", - "Open a .figcraft or .json document (FigCraft or REST API file JSON), a .fig file (best effort, with `warnings` for what wasn't imported), an .svg (becomes vectors) or an image (becomes an image layer) as the active document. Returns the new document's summary.", + "Open a .figcraft or .json document (FigCraft or REST API file JSON), a .fig or .sketch file (best effort, with `warnings` for what wasn't imported; .sketch from Sketch 48 or newer), an .svg (becomes vectors) or an image (becomes an image layer) as the active document. Returns the new document's summary.", obj( json!({ "path": string("File path"), diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f7225e682..8bf553b31 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -86,6 +86,8 @@ pub struct Face { pub id: u32, pub family: String, pub style: String, + /// The face's PostScript name ("HelveticaNeue-Bold"). + pub postscript: Option, pub weight: u16, pub italic: bool, pub bytes: FontBytes, @@ -106,6 +108,8 @@ pub struct Face { /// Variation axes (empty for static fonts) and named instances. pub axes: Vec, pub instances: Vec, + /// The `trak` table's normal track: (size in points, tracking in font units), by size. + track: Vec<(f64, f64)>, charmap: HashMap, outlines: Mutex>>, advances: Mutex>, @@ -123,6 +127,29 @@ impl std::fmt::Debug for Face { } impl Face { + /// The tracking the font's `trak` table gives text of `size` (pixels per glyph; 0 without a + /// table): Apple's platform text engine adds it on its own, FigCraft's doesn't. + pub fn tracking(&self, size: f64) -> f64 { + let (Some(first), Some(last)) = (self.track.first(), self.track.last()) else { return 0.0 }; + if !size.is_finite() || self.upem <= 0.0 { + return 0.0; + } + let units = if size <= first.0 { + first.1 + } else if size >= last.0 { + last.1 + } else { + self.track + .windows(2) + .find_map(|w| match w { + [a, b] if size >= a.0 && size <= b.0 && b.0 > a.0 => Some(a.1 + (b.1 - a.1) * (size - a.0) / (b.0 - a.0)), + _ => None, + }) + .unwrap_or(0.0) + }; + units * size / self.upem + } + pub fn from_bytes(bytes: FontBytes, index: u32, family_override: Option<&str>, style_override: Option<&str>) -> Option { let data = bytes.data(); let f = skrifa::FontRef::from_index(data, index).ok()?; @@ -147,6 +174,7 @@ impl Face { let charmap: HashMap = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect(); let (axes, instances) = variation_info(&f); let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some(); + let track = f.data_for_tag(skrifa::Tag::new(b"trak")).map(|t| normal_track(t.as_bytes())).unwrap_or_default(); let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT"); let colr = has_table(b"COLR"); let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06)); @@ -159,6 +187,7 @@ impl Face { id: NEXT_FACE.fetch_add(1, Ordering::Relaxed), italic: !matches!(attrs.style, skrifa::attribute::Style::Normal) || style.to_ascii_lowercase().contains("italic"), weight, + postscript: name_string(&f, StringId::POSTSCRIPT_NAME), family, style, index, @@ -173,6 +202,7 @@ impl Face { strike_pos: strike, shaper, gsub_features, + track, axes, instances, charmap, @@ -398,6 +428,31 @@ fn name_string(f: &skrifa::FontRef, id: StringId) -> Option { (!s.is_empty()).then_some(s) } +/// The normal (0) horizontal track of a `trak` table: (size, tracking in font units) by size. Empty +/// when the table is missing, damaged or has no normal track. +fn normal_track(t: &[u8]) -> Vec<(f64, f64)> { + let u16_at = |o: usize| t.get(o..o + 2).map(|b| u16::from_be_bytes([b[0], b[1]])); + let i16_at = |o: usize| t.get(o..o + 2).map(|b| i16::from_be_bytes([b[0], b[1]])); + let fixed_at = |o: usize| t.get(o..o + 4).map(|b| f64::from(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) / 65536.0); + let Some(horiz) = u16_at(6).map(usize::from).filter(|o| *o > 0) else { return Vec::new() }; + let (Some(tracks), Some(sizes)) = (u16_at(horiz).map(usize::from), u16_at(horiz + 2).map(usize::from)) else { return Vec::new() }; + let Some(size_table) = t.get(horiz + 4..horiz + 8).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize) else { return Vec::new() }; + let sizes = sizes.min(256); + for k in 0..tracks.min(64) { + let entry = horiz + 8 + k * 8; + if fixed_at(entry) != Some(0.0) { + continue; + } + let Some(values) = u16_at(entry + 6).map(usize::from) else { return Vec::new() }; + let out: Vec<(f64, f64)> = (0..sizes) + .filter_map(|i| Some((fixed_at(size_table + i * 4)?, f64::from(i16_at(values + i * 2)?)))) + .filter(|(s, _)| s.is_finite()) + .collect(); + return if out.windows(2).all(|w| w[0].0 < w[1].0) { out } else { Vec::new() }; + } + Vec::new() +} + /// (family, style) preferring the typographic names. fn names(f: &skrifa::FontRef) -> (Option, Option) { let family = name_string(f, StringId::TYPOGRAPHIC_FAMILY_NAME).or_else(|| name_string(f, StringId::FAMILY_NAME)); @@ -446,6 +501,8 @@ struct Pending { path: std::path::PathBuf, index: u32, style: String, + /// The face's PostScript name. + postscript: Option, } #[derive(Default)] @@ -474,6 +531,8 @@ pub struct Rescan { static DB: OnceLock> = OnceLock::new(); /// Set by [`scan_system_fonts_on_demand`]. static ON_DEMAND: AtomicBool = AtomicBool::new(false); +/// Set by [`opt_out_of_system_fonts`]. +static OPTED_OUT: AtomicBool = AtomicBool::new(false); /// Done once per process: concurrent callers wait for the one scan. static SCANNED: OnceLock<()> = OnceLock::new(); /// Directory walks done (at most one). @@ -523,6 +582,16 @@ impl FontDb { (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect() } + /// The family and style of the face whose PostScript name is `ps` (ignoring case), loaded or + /// only scanned. + pub fn postscript_face(&self, ps: &str) -> Option<(String, String)> { + let is = |p: &Option| p.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(ps)); + if let Some(f) = self.faces.iter().find(|f| is(&f.postscript)) { + return Some((f.family.clone(), f.style.clone())); + } + self.pending.values().find_map(|(display, entries)| entries.iter().find(|e| is(&e.postscript)).map(|e| (display.clone(), e.style.clone()))) + } + /// Family display names, sorted (loaded and known system families). pub fn families(&self) -> Vec { let mut v: Vec = self.families.values().map(|(d, _)| d.clone()).collect(); @@ -539,7 +608,7 @@ impl FontDb { /// Style names of a family, by weight then italic. pub fn styles(&mut self, family: &str) -> Vec { self.load_family(family, None); - let Some((_, idx)) = self.families.get(&family.to_lowercase()) else { return vec![] }; + let Some((_, idx)) = self.families.get(&self.key(family)) else { return vec![] }; // (weight, italic, order in the font, name): named instances keep the font's order. let mut v: Vec<(u16, bool, usize, String)> = Vec::new(); for f in idx.iter().filter_map(|i| self.faces.get(*i)) { @@ -560,15 +629,31 @@ impl FontDb { v.into_iter().map(|x| x.3).collect() } - pub fn has_family(&self, family: &str) -> bool { + /// The key `family` is known under: itself, or for a name designs use for Apple's system font + /// (SF Pro, SF UI Text, …) the system font when no font of that name is installed. + pub fn key(&self, family: &str) -> String { let k = family.to_lowercase(); + let known = |k: &str| self.families.contains_key(k) || self.pending.contains_key(k); + if known(&k) { + return k; + } + match apple_alias(&k) { + Some(target) if known(target) => target.to_string(), + _ => k, + } + } + + pub fn has_family(&self, family: &str) -> bool { + let k = self.key(family); self.families.contains_key(&k) || self.pending.contains_key(&k) } /// Read a system family's faces from disk; with `only`, just the face nearest that style (the /// rest stay pending). fn load_family(&mut self, family: &str, only: Option<&str>) { - let key = family.to_lowercase(); + let key = self.key(family); + // Faces load under the family name the scan gave them (a hidden family's public name). + let display = self.pending.get(&key).map(|(d, _)| d.clone()); let entries = match only { None => match self.pending.remove(&key) { Some((_, entries)) => entries, @@ -602,7 +687,7 @@ impl FontDb { Err(_) => continue, }, }; - if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) { + if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, display.as_deref(), Some(&e.style)) { self.add(face); self.loaded_files.insert((e.path, e.index)); } @@ -619,7 +704,7 @@ impl FontDb { /// `style`: a fallback for missing characters needs one face, not every file of a CJK family /// (10–20 MB each). The family's other faces load when it is used by name. pub fn find_one(&mut self, family: &str, style: &str) -> Option> { - if !self.families.contains_key(&family.to_lowercase()) { + if !self.families.contains_key(&self.key(family)) { self.load_family(family, Some(style)); } self.best(family, style) @@ -627,7 +712,7 @@ impl FontDb { /// The loaded face of `family` nearest `style`. fn best(&self, family: &str, style: &str) -> Option> { - let (_, idx) = self.families.get(&family.to_lowercase())?; + let (_, idx) = self.families.get(&self.key(family))?; let faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect(); let want = norm_style(style); if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| f.instance(style).is_some())) { @@ -842,8 +927,13 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap Option> { Some(buf) } -/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color +/// (index, family, style, PostScript name) of each face in a font file: outline fonts and colour fonts (Apple Color /// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all). -fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { +fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String, Option)> { let count = match FileRef::new(bytes) { Ok(FileRef::Collection(c)) => c.len(), Ok(FileRef::Font(_)) => 1, @@ -911,13 +1001,45 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { .any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()) .then_some(())?; let (family, style) = names(&f); - let family = family.filter(|f| !f.starts_with('.'))?; - Some((i, family, style.unwrap_or_else(|| "Regular".into()))) + let family = public_family(&family?)?; + Some((i, family, style.unwrap_or_else(|| "Regular".into()), name_string(&f, StringId::POSTSCRIPT_NAME))) }) .collect() } +/// The name a font's family is offered under: its own, a public name for Apple's hidden system +/// families that designs use (`.SF Compact` → SF Compact), none for other hidden (`.`) families. +fn public_family(family: &str) -> Option { + let Some(hidden) = family.strip_prefix('.') else { return Some(family.to_string()) }; + match hidden { + "SF Compact" | "SF Compact Rounded" => Some(hidden.to_string()), + "SF NS Rounded" => Some("SF Pro Rounded".into()), + _ => None, + } +} + +/// The family Apple's system fonts are installed under, for the names designs use for them +/// (lowercase): SF Pro and SF UI are the variable `SFNS.ttf` ("System Font"), its text and display +/// cuts its optical sizes. +fn apple_alias(key: &str) -> Option<&'static str> { + Some(match key.trim_start_matches('.') { + "sf pro" | "sf pro text" | "sf pro display" | "sf ui text" | "sf ui display" | "sf ns" | "sf ns text" | "sf ns display" | "san francisco" => { + "system font" + } + "sf compact text" | "sf compact display" => "sf compact", + _ => return None, + }) +} + /// Convenience: find a face with the global db. +/// The family and style of the installed face whose PostScript name is `ps`. +pub fn postscript_face(ps: &str) -> Option<(String, String)> { + if ps.is_empty() { + return None; + } + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).postscript_face(ps) +} + pub fn find_face(family: &str, style: &str) -> Option> { let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); db.find(family, style).or_else(|| db.default_face()) @@ -1012,10 +1134,17 @@ pub fn load_font_bytes(bytes: Vec) -> Vec { families } -/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts. +/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`), or the process opted out +/// ([`opt_out_of_system_fonts`]): renders use only the embedded fonts. #[cfg(not(target_arch = "wasm32"))] fn system_fonts_opted_out() -> bool { - std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") + OPTED_OUT.load(Ordering::Relaxed) || std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") +} + +/// Use only the embedded fonts for the rest of the process, as `FIGCRAFT_NO_SYSTEM_FONTS` does +/// (headless runs with `--no-system-fonts`): no call scans the system fonts after this. +pub fn opt_out_of_system_fonts() { + OPTED_OUT.store(true, Ordering::Relaxed); } /// Every font text can use is known: the system fonts were indexed, or there are none to index diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index b3bcc979e..7b9fba0bd 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -2,7 +2,8 @@ use super::*; fn pending(styles: &[&str]) -> FontDb { let mut db = FontDb::default(); - let entries = styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into() }).collect(); + let entries = + styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into(), postscript: None }).collect(); db.pending.insert("cjk ui".into(), ("CJK UI".into(), entries)); db } @@ -263,7 +264,7 @@ fn the_scan_reads_font_files_without_an_extension() { /// A rescan offers new families and the styles installed since, and never a file twice. #[test] fn a_rescan_offers_fonts_installed_since() { - let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; + let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None }; let mut db = pending(&["Regular"]); let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() @@ -306,6 +307,95 @@ fn font_bytes_load_into_the_db() { } } +/// Apple's system font answers to the names designs use for it (SF Pro, SF UI Text, …), unless a +/// font of that name is installed. +#[test] +fn apple_system_font_names_find_the_system_font() { + let mut db = FontDb::default(); + db.pending.insert( + "system font".into(), + ("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into(), postscript: None }]), + ); + for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] { + assert!(db.has_family(name), "{name}"); + } + assert!(!db.has_family("SF Mono")); + db.pending.insert("sf pro".into(), ("SF Pro".into(), Vec::new())); + assert_eq!(db.key("SF Pro"), "sf pro", "an installed SF Pro is itself"); + assert_eq!(db.key("SF UI Text"), "system font"); +} + +#[test] +fn hidden_apple_families_take_public_names() { + assert_eq!(public_family(".SF Compact").as_deref(), Some("SF Compact")); + assert_eq!(public_family(".SF Compact Rounded").as_deref(), Some("SF Compact Rounded")); + assert_eq!(public_family(".SF NS Rounded").as_deref(), Some("SF Pro Rounded")); + assert_eq!(public_family(".Keyboard"), None); + assert_eq!(public_family("Inter").as_deref(), Some("Inter")); +} + +/// A named style keeps its own optical size whatever the text size (existing documents render as +/// before); a custom `opsz` value (what a `.sketch` import sets) wins. Needs a variable font with an +/// optical size axis and named instances: macOS's or Windows' system font. +#[test] +fn named_styles_keep_their_optical_size() { + let Some(bytes) = ["/System/Library/Fonts/SFNS.ttf", "C:/Windows/Fonts/SegUIVar.ttf"].iter().find_map(|p| std::fs::read(p).ok()) else { + return; + }; + let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap(); + let opsz = face.axes.iter().position(|a| a.tag == "opsz").unwrap(); + let inst = face.instances.first().unwrap(); + let mut style = figcraft_doc::TextStyle { font_style: inst.name.clone(), ..Default::default() }; + for size in [1.0, 12.0, 40.0, 200.0] { + style.font_size = size; + assert_eq!(face.axis_values(&style)[opsz], inst.coords[opsz], "{} at {size}", inst.name); + } + style.variations.insert("opsz".into(), 1e9); + assert_eq!(face.axis_values(&style)[opsz], face.axes[opsz].max); +} + +/// The tracking a font's `trak` table gives at a size (Apple's fonts carry one; the platform text +/// engine applies it on its own), in pixels, interpolated between the sizes the table lists. +#[test] +fn trak_tracking_by_size() { + let Ok(bytes) = std::fs::read("/System/Library/Fonts/SFNS.ttf") else { return }; + let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap(); + // Track 0 of SF: −12 units at 13 pt, 0 at 12 pt, −40 at 16 pt (2048 units per em). + assert!((face.tracking(13.0) - (-12.0 * 13.0 / 2048.0)).abs() < 1e-9, "{}", face.tracking(13.0)); + assert!(face.tracking(12.0).abs() < 1e-9); + assert!((face.tracking(12.5) - (-6.0 * 12.5 / 2048.0)).abs() < 1e-9); + // Past the table's ends: its last values. + assert!(face.tracking(500.0).is_finite()); + let plain = crate::fontdb::db().write().unwrap().default_face(); + if let Some(f) = plain.filter(|f| f.family != "System Font") { + let _ = f.tracking(12.0); + } +} + +/// A face is found by its PostScript name (ignoring case), loaded or only scanned: the family and +/// style it is offered under. +#[test] +fn faces_by_postscript_name() { + let mut db = FontDb::default(); + db.pending.insert( + "brand sans".into(), + ( + "Brand Sans".into(), + vec![Pending { path: "no-such-dir/b.ttf".into(), index: 0, style: "Bold".into(), postscript: Some("BrandSans-Bold".into()) }], + ), + ); + assert_eq!(db.postscript_face("brandsans-bold"), Some(("Brand Sans".to_string(), "Bold".to_string()))); + assert_eq!(db.postscript_face("BrandSans-Light"), None); + if let Some(f) = CRAFT_FONTS.first() + && let Some(face) = Face::from_bytes(FontBytes::Static(f.bytes), 0, Some(f.family), Some(f.style)) + && let Some(ps) = face.postscript.clone() + { + db.add(face); + assert_eq!(db.postscript_face(&ps), Some((f.family.to_string(), f.style.to_string()))); + } + assert!(postscript_face("").is_none()); +} + #[test] fn every_platform_has_a_thai_font_in_the_fallbacks() { // Thai typed into a Latin layer (new text starts in Inter) is drawn by the first fallback that diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 26ffdb7a6..8da0b2d80 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,8 +17,8 @@ pub use breaks::{backspace_step, grapheme_step}; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, - has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, - system_fonts_scanned, + has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts, rescan_system_fonts, scan_system_fonts, + scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 43f628c8b..0d53b224a 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -427,7 +427,7 @@ status = "draft — needs native review" "Draw" = "আঁকুন" "Draw a frame" = "একটি ফ্রেম আঁকুন" "Drawing tools" = "অঙ্কন টুলস" -"Drop a .figcraft, .fig, .json, .svg or image file." = "একটি .figcraft, .fig, .json, .svg বা ছবির ফাইল ড্রপ করুন।" +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "একটি .figcraft, .fig, .sketch, .json, .svg বা ছবির ফাইল ড্রপ করুন।" "Drop to import" = "ইম্পোর্ট করতে ড্রপ করুন" "Drop to open" = "খুলতে ড্রপ করুন" "Drop shadow" = "ড্রপ শ্যাডো" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 814781f12..78c1dc6de 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -410,7 +410,7 @@ status = "draft — needs native review" "Drag the stroke to add a width point" = "Ziehen Sie an der Kontur, um einen Breitenpunkt hinzuzufügen" "Draw" = "Zeichnen" "Drawing tools" = "Zeichenwerkzeuge" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .json-, .svg- oder Bilddatei ab." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .sketch-, .json-, .svg- oder Bilddatei ab." "Drop to import" = "Zum Importieren ablegen" "Drop to open" = "Zum Öffnen ablegen" "Drop shadow" = "Schlagschatten" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3dcec06f2..aca32d013 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -439,7 +439,7 @@ name = "English" "Draw" = "Draw" "Draw a frame" = "Draw a frame" "Drawing tools" = "Drawing tools" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Drop a .figcraft, .fig, .json, .svg or image file." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Drop a .figcraft, .fig, .sketch, .json, .svg or image file." "Drop to import" = "Drop to import" "Drop to open" = "Drop to open" "Drop shadow" = "Drop shadow" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 35d933c51..84e63e5d8 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -435,7 +435,7 @@ status = "draft — needs native review" "Draw" = "Dibujar" "Draw a frame" = "Dibuja un marco" "Drawing tools" = "Herramientas de dibujo" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .json, .svg o de imagen." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .sketch, .json, .svg o de imagen." "Drop to import" = "Suelta para importar" "Drop to open" = "Suelta para abrir" "Drop shadow" = "Sombra paralela" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 1a35b3274..d8bbb4ecf 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -429,7 +429,7 @@ status = "draft — needs native review" "Draw" = "Piirrä" "Draw a frame" = "Piirrä kehys" "Drawing tools" = "Piirtotyökalut" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .json-, .svg- tai kuvatiedosto." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .sketch-, .json-, .svg- tai kuvatiedosto." "Drop shadow" = "Heittovarjo" "Drop to import" = "Tuo pudottamalla" "Drop to open" = "Avaa pudottamalla" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b4c35f2f6..5f846fcdb 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -428,7 +428,7 @@ status = "draft — needs native review" "Draw" = "Dessin" "Draw a frame" = "Tracer un cadre" "Drawing tools" = "Outils de dessin" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .sketch, .json, .svg ou une image." "Drop shadow" = "Ombre portée" "Drop to import" = "Déposer pour importer" "Drop to open" = "Déposer pour ouvrir" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index a4a709541..181848f4c 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -415,7 +415,7 @@ status = "draft — needs native review" "Draw" = "Disegna" "Draw a frame" = "Disegna un frame" "Drawing tools" = "Strumenti di disegno" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Rilascia un file .figcraft, .fig, .json, .svg o un'immagine." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Rilascia un file .figcraft, .fig, .sketch, .json, .svg o un'immagine." "Drop to import" = "Rilascia per importare" "Drop to open" = "Rilascia per aprire" "Drop shadow" = "Ombra esterna" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index b1309b553..902fc7a18 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -429,7 +429,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Draw" = "ხატვა" "Draw a frame" = "დახატეთ ჩარჩო" "Drawing tools" = "ხატვის ხელსაწყოები" -"Drop a .figcraft, .fig, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .json, .svg ან გამოსახულების ფაილი." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .sketch, .json, .svg ან გამოსახულების ფაილი." "Drop to import" = "ჩააგდეთ იმპორტისთვის" "Drop to open" = "ჩააგდეთ გასახსნელად" "Drop shadow" = "ჩრდილი" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 126303aae..667af3b02 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -429,7 +429,7 @@ status = "draft — needs native review" "Draw" = "Tekenen" "Draw a frame" = "Teken een frame" "Drawing tools" = "Tekengereedschap" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .json-, .svg- of afbeeldingsbestand los." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .sketch-, .json-, .svg- of afbeeldingsbestand los." "Drop shadow" = "Slagschaduw" "Drop to import" = "Loslaten om te importeren" "Drop to open" = "Loslaten om te openen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index de7821cd7..44f294791 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -428,7 +428,7 @@ name = "Polski" "Draw" = "Rysuj" "Draw a frame" = "Narysuj ramkę" "Drawing tools" = "Narzędzia rysowania" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .json, .svg lub plik obrazu." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .sketch, .json, .svg lub plik obrazu." "Drop to import" = "Upuść, aby zaimportować" "Drop to open" = "Upuść, aby otworzyć" "Drop shadow" = "Cień" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index be6708bfc..81c449532 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1710,7 +1710,7 @@ status = "draft — needs native review" "Dec" = "dez." "Design system starter" = "Ponto de partida para sistema de design" "Draw a frame" = "Desenhe um quadro" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .json, .svg ou uma imagem." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .sketch, .json, .svg ou uma imagem." "Drop to import" = "Solte para importar" "Drop to open" = "Solte para abrir" "Edited {when}" = "Editado {when}" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 080454a0a..0f833a4fb 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -428,7 +428,7 @@ name = "Русский" "Draw" = "Рисование" "Draw a frame" = "Нарисовать Frame" "Drawing tools" = "Инструменты рисования" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .json, .svg или изображение." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .sketch, .json, .svg или изображение." "Drop shadow" = "Внешняя тень" "Drop to import" = "Отпустите, чтобы импортировать" "Drop to open" = "Отпустите, чтобы открыть" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index a7900ab90..12cd4bb8e 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -428,7 +428,7 @@ name = "Svenska" "Draw" = "Rita" "Draw a frame" = "Rita en frame" "Drawing tools" = "Ritverktyg" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .json- eller .svg-fil eller en bildfil." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .sketch-, .json- eller .svg-fil eller en bildfil." "Drop to import" = "Släpp för att importera" "Drop to open" = "Släpp för att öppna" "Drop shadow" = "Slagskugga" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index ef1a57e7e..5b97ab1b7 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -416,7 +416,7 @@ status = "draft — needs native review" "Draw" = "Vẽ" "Draw a frame" = "Vẽ khung" "Drawing tools" = "Công cụ vẽ" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Thả tệp .figcraft, .fig, .json, .svg hoặc hình ảnh vào đây." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Thả tệp .figcraft, .fig, .sketch, .json, .svg hoặc hình ảnh vào đây." "Drop shadow" = "Đổ bóng" "Drop to import" = "Thả để nhập" "Drop to open" = "Thả để mở" diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 9c809b994..922c0fda0 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -181,7 +181,7 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { let files = ctx.input(|i| i.raw.dropped_files.clone()); for f in files { if !crate::opens(f.path()) { - app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file.")); + app.toast(tr("Drop a .figcraft, .fig, .sketch, .json, .svg or image file.")); continue; } crate::pending_open::open_dropped(app, &f); diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 0e151da24..3525b31f0 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -764,7 +764,7 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, file: &Shown) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); match FileKind::of(Path::new(&file.path)) { - FileKind::FigCraft | FileKind::Fig => { + FileKind::FigCraft | FileKind::Fig | FileKind::Sketch => { ui.painter().rect_filled(r, CornerRadius::same(4), t.accent); icons::paint(ui.painter(), r.shrink(3.0), icons::file_mark(file.kind), t.accent_on); } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index ebbf6d95e..71225db4b 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -898,5 +898,12 @@ fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() { drop_file(&mut u, &path); assert!(u.app.session.docs.is_empty()); assert!(u.app.ui.home); - assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .json, .svg or image file.")); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .sketch, .json, .svg or image file.")); +} + +#[test] +fn sketch_files_open_and_drop_as_documents() { + let p = std::path::Path::new("/tmp/Design.SKETCH"); + assert!(crate::opens(p)); + assert!(crate::is_document(p)); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ac4565878..46eaf32bd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1523,7 +1523,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"] + &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; self.pick_file(title, exts, true, |app, file| { @@ -2315,17 +2315,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", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; /// Whether `path` names a file type that opens as a document (the extension, in any case). pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(OsStr::to_str).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG: +/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.sketch`, file JSON) rather than an image or SVG: /// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. pub(crate) fn is_document(path: &Path) -> bool { - path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "sketch", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Reads the contents of dropped file `name` that has no file on disk to read by path. diff --git a/docs/cli.md b/docs/cli.md index 5ff71604a..1e1e1967b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,7 +46,8 @@ layers}]` with `--json`); their text renders in a fallback font. Every subcommand that reads a file also takes the public REST API file JSON (the response of `GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly. Image fills come from `images/.png` (or `.jpg`, …) next to the JSON when present, else a -grey placeholder. `.fig` files are read the same way (best effort, images from the file itself); +grey placeholder. `.fig` files are read the same way (best effort, images from the file itself), and +so are `.sketch` files saved by Sketch 48 or newer; `info` lists what wasn't imported as `note:` lines. ```text @@ -100,7 +101,7 @@ figcraft-cli export app.figcraft -o handoff.fig ### `convert ` -By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.svg` (an editable frame: groups, +By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.sketch` (Sketch 48 or newer, best effort), `.svg` (an editable frame: groups, 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 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a530d8a5d..48cacbbbd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (`.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -18,6 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (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 | +| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | @@ -169,6 +170,7 @@ each. | 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 | +| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 68 and 83 open; the Sketch 47 edition is refused). Numbers unchanged | | 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged | | 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index b6084a572..aaa953a0c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -317,7 +317,7 @@ Or in a project's `.mcp.json`: | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | -| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | +| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | | `undo` / `redo` | Step through history. | | `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | | `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. | diff --git a/docs/roadmap.md b/docs/roadmap.md index d0bdbaa7b..83b9c2235 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (`.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -54,7 +54,7 @@ blockers. | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | | M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13) | spell check, Figma Draw multi-edit | 7–18 | | M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | -| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | +| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| | 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. Milestone M12 Boards added; AI features move to M13 | +| 2026-10-21 | minor | M6: best-effort `.sketch` import (Sketch 48 and newer), see [file-format-parity](file-format-parity.md). Percentages unchanged | | 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 | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | | 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 50fda8ccb..c27f8ad2f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (`.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -109,7 +109,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean, text and instance-swap properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | -| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | +| 14 | Import/export | 6 | 87% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 98% | 78% | Publishing mappings from the code repository; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode, dev resources links, import lines, compare with versions, an inspect-only canvas and layers panel and measurements kept on the canvas landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–2 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | @@ -127,7 +127,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects |---|---|---|---|---|---| | Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | -| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | +| File formats | 12 | 61% | 5–10 | `.sketch` read (Sketch 48 and newer, best effort; 2026-10-21): live symbols, shared styles, Sketch 2025 frames and stacks, checked on three real files against Sketch's own renders (0.8–2% of pixels differ on average); `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | @@ -647,6 +647,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | +| 2026-10-21 | minor | `.sketch` import (best effort, Sketch 48 and newer), a feature Figma has (its file browser imports Sketch files, desktop app included, per the help center) that the area table hadn't listed: pages, layers, styles, live symbols with overrides, shared styles and Color Variables, prototype links, Sketch 2025 frames, stacks as auto layout and tints, text placed as Sketch places it; checked on three real files (Sketch 50, 64 and 2026) against Sketch's own renders. Import/export breadth 86% → 87% (ready stays 58%: best effort, three files), file formats 60% → 61%; feature breadth (~85%), ready for real work (~53%) and the audience numbers unchanged after rounding | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/packaging/linux/ai.storyteller.figcraft.desktop b/packaging/linux/ai.storyteller.figcraft.desktop index 9c0adcf9f..c739d6a47 100644 --- a/packaging/linux/ai.storyteller.figcraft.desktop +++ b/packaging/linux/ai.storyteller.figcraft.desktop @@ -11,4 +11,4 @@ StartupNotify=true StartupWMClass=ai.storyteller.figcraft Categories=Graphics;2DGraphics;VectorGraphics;X-Design; Keywords=design;interface;ui;ux;prototype;wireframe;mockup;vector;layout;components; -MimeType=application/x-figcraft;image/svg+xml;image/png;image/jpeg;x-scheme-handler/figcraft; +MimeType=application/x-figcraft;image/svg+xml;image/png;image/jpeg;application/x-sketch;x-scheme-handler/figcraft; diff --git a/packaging/linux/ai.storyteller.figcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.figcraft.metainfo.xml.in index 2dc341a7e..43618eb18 100644 --- a/packaging/linux/ai.storyteller.figcraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.figcraft.metainfo.xml.in @@ -58,6 +58,7 @@ figcraft application/x-figcraft + application/x-sketch diff --git a/packaging/linux/ai.storyteller.figcraft.mime.xml b/packaging/linux/ai.storyteller.figcraft.mime.xml index 7c18c9a81..b2190b216 100644 --- a/packaging/linux/ai.storyteller.figcraft.mime.xml +++ b/packaging/linux/ai.storyteller.figcraft.mime.xml @@ -1,6 +1,6 @@ - @@ -8,4 +8,10 @@ + + + Sketch document + + + diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index bbcec20f8..fc0681732 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -70,6 +70,28 @@ + + UTImportedTypeDeclarations + + + UTTypeIdentifier + com.bohemiancoding.sketch.drawing.single + UTTypeDescription + Sketch Document + UTTypeConformsTo + + public.data + public.content + + UTTypeTagSpecification + + public.filename-extension + sketch + + + + CFBundleURLTypes @@ -108,6 +130,16 @@ LSItemContentTypes public.svg-image + + CFBundleTypeName + Sketch Document + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + com.bohemiancoding.sketch.drawing.single + CFBundleTypeName Image diff --git a/packaging/windows/figcraft.wxs b/packaging/windows/figcraft.wxs index dd89b869e..4cc564f24 100644 --- a/packaging/windows/figcraft.wxs +++ b/packaging/windows/figcraft.wxs @@ -115,6 +115,10 @@ + + + + @@ -124,6 +128,7 @@ +