"), "{}", code.lines().next().unwrap());
// Nesting stops at the depth limit instead of running away.
assert!(code.matches("
"), "{code}");
+ assert!(code.contains("
") && code.contains("
"), "{code}");
+}
+
+#[test]
+fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() {
+ let mut s = Session::new();
+ let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "按钮"})).unwrap());
+ let k = id(s.execute("text.create", &json!({"text": "Ok", "x": 0, "y": 0, "parent": f})).unwrap());
+ s.execute("node.set", &json!({"id": k, "props": {"name": "кнопка"}})).unwrap();
+ let hash = id(s.execute("text.create", &json!({"text": "###", "x": 0, "y": 20, "parent": f})).unwrap());
+ s.execute("node.set", &json!({"id": hash, "props": {"name": "###"}})).unwrap();
+ let digit = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 40, "width": 10, "height": 10, "parent": f})).unwrap());
+ s.execute("node.set", &json!({"id": digit, "props": {"name": "4버튼"}})).unwrap();
+ let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
+ assert!(code.contains("
"), "Chinese: {code}");
+ assert!(code.contains("
"), "Cyrillic: {code}");
+ assert!(code.contains("
"), "symbols fall back to the kind: {code}");
+ assert!(code.contains("layer-4beoteun"), "a leading digit keeps the layer- prefix: {code}");
+}
+
+#[test]
+fn long_names_cut_at_a_word_boundary() {
+ let mut s = Session::new();
+ let long = format!("{}-{}", "a".repeat(30), "b".repeat(30));
+ let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": long})).unwrap());
+ let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
+ let word = "a".repeat(30);
+ assert!(code.contains(&format!("
")), "cut back to the first word: {code}");
+ let unbroken = "c".repeat(50);
+ let g = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": unbroken})).unwrap());
+ let code = code_for(s.doc().unwrap(), g, CodeLang::Html);
+ assert!(code.contains(&format!("class=\"{}\"", "c".repeat(40))), "one long word cuts at 40: {code}");
+}
+
#[test]
fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
let mut s = Session::new();
diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs
index 33162314..7dccd706 100644
--- a/crates/engine/src/inspect.rs
+++ b/crates/engine/src/inspect.rs
@@ -188,6 +188,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
o.insert("textAlign".into(), json!(t.align_h));
o.insert("textAutoResize".into(), json!(t.auto_resize));
+ if t.max_lines > 0 {
+ o.insert("maxLines".into(), json!(t.max_lines));
+ }
if let Some(tp) = &t.path {
o.insert("textPath".into(), json!(tp));
}
diff --git a/crates/engine/src/inspect_behaviour_tests.rs b/crates/engine/src/inspect_behaviour_tests.rs
index c4bcc6be..780aa19c 100644
--- a/crates/engine/src/inspect_behaviour_tests.rs
+++ b/crates/engine/src/inspect_behaviour_tests.rs
@@ -340,6 +340,13 @@ fn rem_conversion_leaves_names_alone() {
let f = crate::codegen::px_to_rem;
// Lengths convert, names ending in "px" don't.
assert_eq!(f("width: 32px; --gap-8px: 1; margin: -8px 0px;", 16.0), "width: 2rem; --gap-8px: 1; margin: -0.5rem 0rem;");
- assert_eq!(f("épx 1px px", 16.0), "épx 0.06rem px");
+ assert_eq!(f("épx 1px px", 16.0), "épx 0.0625rem px");
assert_eq!(f("", 16.0), "");
}
+
+#[test]
+fn rem_conversion_keeps_the_original_pixels() {
+ let f = crate::codegen::px_to_rem;
+ assert_eq!(f("padding: 3px;", 16.0), "padding: 0.1875rem;");
+ assert_eq!(f("gap: 0.5px;", 16.0), "gap: 0.03125rem;");
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 548f96ed..462c184f 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -375,6 +375,9 @@ pub struct DocState {
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option
,
images_left_in_file: Option<(String, usize)>,
+ /// The file on disk as this tab last read or wrote it: a save refuses to replace what another
+ /// app (figcraft-cli, say) wrote to it since (#1330).
+ on_disk: Option,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
@@ -438,6 +441,7 @@ impl DocState {
view_from_prefs: false,
last_duplicate: None,
images_left_in_file: None,
+ on_disk: None,
};
st.zoom_to_fit();
st
@@ -448,6 +452,11 @@ impl DocState {
self
}
+ /// The tab's file now holds what this tab read or wrote.
+ pub(crate) fn note_on_disk(&mut self) {
+ self.on_disk = self.path.as_deref().and_then(|p| library::stat(p).ok());
+ }
+
pub fn images_lost_saving_to(&self, path: &str) -> Option {
self.images_left_in_file.as_ref().filter(|(file, _)| file_access::same_file(file, path)).map(|(_, count)| *count)
}
@@ -803,6 +812,10 @@ pub struct Session {
/// Command log (id + params) for actions/automation. File imports that carry the file bytes
/// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file.
pub journal: Vec<(String, Value)>,
+ /// Nesting depth of [`Session::grouped`]; tabs closed while it is above zero are kept in
+ /// `closed_in_group` so a failed group can bring them back.
+ group_depth: usize,
+ closed_in_group: Vec,
/// Recently used colours.
pub recent_colors: Vec,
/// The last undoable command executed through [`Session::execute`], excluding "Repeat".
@@ -858,6 +871,8 @@ impl Session {
ui_requests: Vec::new(),
ui_dark: None,
journal: Vec::new(),
+ group_depth: 0,
+ closed_in_group: Vec::new(),
recent_colors: Vec::new(),
last_command: None,
message: None,
@@ -1297,6 +1312,16 @@ impl Session {
Ok(())
}
+ /// Take the tabs `pred` matches out of `docs`; inside a group they are kept so that a failed
+ /// group can reopen them.
+ pub(crate) fn remove_tabs(&mut self, mut pred: impl FnMut(&DocState) -> bool) {
+ let (gone, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.docs).into_iter().partition(|d| pred(d));
+ self.docs = keep;
+ if self.group_depth > 0 {
+ self.closed_in_group.extend(gone);
+ }
+ }
+
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
/// own, and when it fails the documents stay at their group-start state.
pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result {
@@ -1315,13 +1340,23 @@ impl Session {
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, true);
}
+ self.group_depth += 1;
let result = f(self);
+ self.group_depth -= 1;
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, false);
}
if result.is_err() {
- self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
+ // Tabs the group closed come back from the stash, and the group-start order returns.
+ let mut current = std::mem::take(&mut self.docs);
+ for (uid, ..) in &before {
+ let tab = match current.iter().position(|d| d.uid == *uid) {
+ Some(i) => Some(current.swap_remove(i)),
+ None => self.closed_in_group.iter().position(|d| d.uid == *uid).map(|i| self.closed_in_group.swap_remove(i)),
+ };
+ self.docs.extend(tab);
+ }
for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) {
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
ds.history.redo = redo;
@@ -1334,8 +1369,14 @@ impl Session {
}
self.active = active;
self.journal.truncate(journal_len);
+ if self.group_depth == 0 {
+ self.closed_in_group.clear();
+ }
return result;
}
+ if self.group_depth == 0 {
+ self.closed_in_group.clear();
+ }
let changed: Vec<_> = before
.iter()
@@ -1854,6 +1895,8 @@ mod proto_delay_tests;
#[cfg(test)]
mod proto_multi_tests;
#[cfg(test)]
+mod proto_overlay_anchor_tests;
+#[cfg(test)]
mod proto_polish_tests;
#[cfg(test)]
mod proto_present_tests;
@@ -1874,6 +1917,8 @@ mod rotated_select_tests;
#[cfg(test)]
mod rotation_origin_tests;
#[cfg(test)]
+mod save_disk_changes_tests;
+#[cfg(test)]
mod save_format_tests;
#[cfg(test)]
mod scale_tool_tests;
@@ -1952,6 +1997,8 @@ mod variants_discovery_tests;
#[cfg(test)]
mod vector_behaviour_tests;
#[cfg(test)]
+mod vector_drawable_export_tests;
+#[cfg(test)]
mod vector_fill_rule_tests;
#[cfg(test)]
mod vector_multi_behaviour_tests;
@@ -1966,6 +2013,8 @@ mod version_tests;
#[cfg(test)]
mod view_behaviour_tests;
#[cfg(test)]
+mod vue_codegen_tests;
+#[cfg(test)]
mod widget_input_tests;
#[cfg(test)]
mod widgets_tests;
diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs
index cc6c3856..40d6b3ca 100644
--- a/crates/engine/src/library.rs
+++ b/crates/engine/src/library.rs
@@ -439,9 +439,9 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,
Ok(PublishedLibrary { manifest, doc: Arc::new(snap) })
}
-/// When a library file was last read.
+/// When a file was last read: its length and modification time (also a document's own file, #1330).
#[derive(Clone, Debug, PartialEq)]
-struct Stamp {
+pub(crate) struct Stamp {
len: u64,
modified: Option,
}
@@ -545,7 +545,7 @@ fn now() -> Option {
}
#[cfg(not(target_arch = "wasm32"))]
-fn stat(path: &str) -> std::result::Result {
+pub(crate) fn stat(path: &str) -> std::result::Result {
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
if !m.is_file() {
return Err(format!("{path} is not a file"));
@@ -554,7 +554,7 @@ fn stat(path: &str) -> std::result::Result {
}
#[cfg(target_arch = "wasm32")]
-fn stat(_path: &str) -> std::result::Result {
+pub(crate) fn stat(_path: &str) -> std::result::Result {
Err(NO_FILES.into())
}
diff --git a/crates/engine/src/playground.rs b/crates/engine/src/playground.rs
index 0d836f6a..90a627b0 100644
--- a/crates/engine/src/playground.rs
+++ b/crates/engine/src/playground.rs
@@ -52,7 +52,7 @@ pub fn specs() -> Vec {
vec![spec!(
"component.playground",
"Component playground",
- "{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
+ "{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
- Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose|arkui"))?,
+ Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
diff --git a/crates/engine/src/proto_overlay_anchor_tests.rs b/crates/engine/src/proto_overlay_anchor_tests.rs
new file mode 100644
index 00000000..ebf9a30c
--- /dev/null
+++ b/crates/engine/src/proto_overlay_anchor_tests.rs
@@ -0,0 +1,70 @@
+use serde_json::{Value, json};
+
+use crate::params::id_of;
+use crate::{EngineError, Session};
+use figcraft_doc::NodeId;
+use figcraft_doc::proto::{Action, OverlayAnchor, OverlayPosition};
+
+fn frame(s: &mut Session, x: f64, w: f64, h: f64, name: &str) -> NodeId {
+ id_of(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": w, "height": h, "name": name})).unwrap()["id"]).unwrap()
+}
+
+fn button(s: &mut Session, parent: NodeId) -> NodeId {
+ let r = s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 30, "width": 100, "height": 40, "parent": parent, "name": "Button"}));
+ id_of(&r.unwrap()["id"]).unwrap()
+}
+
+fn action(s: &Session, id: NodeId) -> Action {
+ s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].clone()
+}
+
+fn dropdown(s: &mut Session) -> (NodeId, NodeId) {
+ let screen = frame(s, 0.0, 400.0, 800.0, "Screen");
+ let menu = frame(s, 600.0, 60.0, 200.0, "Menu");
+ let b = button(s, screen);
+ let open = json!({"type": "openOverlay", "dest": menu, "position": "bottomLeft", "anchor": "triggerOutside"});
+ s.execute("proto.interaction.add", &json!({"id": b, "action": open})).unwrap();
+ (b, menu)
+}
+
+#[test]
+fn the_anchor_is_set_read_and_undone() {
+ let mut s = Session::new();
+ let (b, menu) = dropdown(&mut s);
+ assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerOutside, position: OverlayPosition::BottomLeft, .. }));
+ s.execute(
+ "proto.interaction.set",
+ &json!({"id": b, "index": 0, "action": {"type": "openOverlay", "dest": menu, "position": "topCenter", "anchor": "triggerInside"}}),
+ )
+ .unwrap();
+ assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerInside, position: OverlayPosition::TopCenter, .. }));
+ assert!(s.undo().unwrap());
+ assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerOutside, .. }));
+}
+
+#[test]
+fn fig_writes_a_manual_offset_from_the_trigger() {
+ let mut s = Session::new();
+ let (b, _) = dropdown(&mut s);
+ let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
+ let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
+ let hot = back.nodes().find(|n| n.name == "Button").unwrap();
+ assert!(
+ matches!(hot.interactions[0].actions[0], Action::OpenOverlay { position: OverlayPosition::Manual, offset: Some([0.0, 40.0]), .. }),
+ "{:?} (from {b:?})",
+ hot.interactions[0].actions
+ );
+}
+
+#[test]
+fn junk_anchors_are_refused_not_fatal() {
+ let junk = [json!(true), json!(-1), json!("above"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))];
+ for j in &junk {
+ let mut s = Session::new();
+ let (a, menu) = (frame(&mut s, 0.0, 100.0, 100.0, "A"), frame(&mut s, 200.0, 50.0, 50.0, "M"));
+ let p: Value = json!({"id": a, "action": {"type": "openOverlay", "dest": menu, "anchor": j}});
+ let r = s.execute("proto.interaction.add", &p);
+ assert!(!matches!(r, Err(EngineError::Internal { .. })), "{p}: {r:?}");
+ assert!(s.doc().unwrap().node(a).unwrap().interactions.is_empty(), "{j}");
+ }
+}
diff --git a/crates/engine/src/save_disk_changes_tests.rs b/crates/engine/src/save_disk_changes_tests.rs
new file mode 100644
index 00000000..807daa9e
--- /dev/null
+++ b/crates/engine/src/save_disk_changes_tests.rs
@@ -0,0 +1,76 @@
+//! Saving never silently replaces what another app wrote to the open file since it was opened or
+//! saved here (#1330): Save and auto-save refuse, `overwrite` and Revert to saved settle it.
+
+use serde_json::json;
+
+use crate::Session;
+use crate::cmd::file::FileSnapshot;
+
+fn temp_file(name: &str) -> String {
+ let dir = std::env::temp_dir().join(format!("figcraft-disk-changes-{}-{name}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ dir.join("TESTE.figcraft").to_string_lossy().into_owned()
+}
+
+/// What the file on disk names its first page.
+fn page_on_disk(path: &str) -> String {
+ let mut s = Session::new();
+ s.execute("file.open", &json!({"path": path})).unwrap();
+ let ds = s.ds().unwrap();
+ ds.doc.node(ds.page).unwrap().name.clone()
+}
+
+/// The app has `path` open with an unsaved edit, after figcraft-cli renamed its page on disk.
+fn app_and_cli(path: &str) -> Session {
+ let mut setup = Session::new();
+ setup.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
+ setup.execute("file.save", &json!({"path": path})).unwrap();
+ let mut app = Session::new();
+ app.execute("file.open", &json!({"path": path})).unwrap();
+ let mut cli = Session::new();
+ cli.execute("file.open", &json!({"path": path})).unwrap();
+ cli.execute("page.rename", &json!({"name": "CLI VENCEU"})).unwrap();
+ cli.execute("file.save", &json!({})).unwrap();
+ app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
+ app
+}
+
+#[test]
+fn saving_over_changes_made_on_disk_is_refused_until_asked() {
+ let path = temp_file("save");
+ let mut app = app_and_cli(&path);
+ let err = app.execute("file.save", &json!({})).unwrap_err().to_string();
+ assert!(err.contains("changed on disk"), "{err}");
+ assert_eq!(page_on_disk(&path), "CLI VENCEU", "the CLI's change is still in the file");
+ assert!(app.ds().unwrap().is_dirty(), "the app's edit stays unsaved");
+ // Asked to, the save replaces it; from then on the file is the app's again.
+ app.execute("file.save", &json!({"overwrite": true})).unwrap();
+ assert_ne!(page_on_disk(&path), "CLI VENCEU");
+ app.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
+ app.execute("file.save", &json!({})).unwrap();
+ let _ = std::fs::remove_file(&path);
+}
+
+#[test]
+fn reverting_loads_the_changes_made_on_disk_and_saves_again() {
+ let path = temp_file("revert");
+ let mut app = app_and_cli(&path);
+ app.execute("file.revert", &json!({})).unwrap();
+ let ds = app.ds().unwrap();
+ assert_eq!(ds.doc.node(ds.page).unwrap().name, "CLI VENCEU");
+ app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
+ app.execute("file.save", &json!({})).unwrap();
+ assert_eq!(page_on_disk(&path), "CLI VENCEU", "saved on top of the CLI's change");
+ let _ = std::fs::remove_file(&path);
+}
+
+#[test]
+fn auto_save_leaves_changes_made_on_disk_alone() {
+ let path = temp_file("auto");
+ let mut app = app_and_cli(&path);
+ let snapshot = FileSnapshot::of(app.ds().unwrap());
+ let bytes = snapshot.encode().unwrap();
+ assert!(snapshot.save(&mut app, &bytes).is_err());
+ assert_eq!(page_on_disk(&path), "CLI VENCEU");
+ let _ = std::fs::remove_file(&path);
+}
diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs
index 8e479d22..02abbfb7 100644
--- a/crates/engine/src/selection_behaviour_tests.rs
+++ b/crates/engine/src/selection_behaviour_tests.rs
@@ -391,6 +391,57 @@ fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() {
assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}");
}
+#[test]
+fn a_layer_stacked_below_the_selection_doesnt_take_a_drag_from_inside_it() {
+ // #1370: inside a selected unfilled shape, a filled layer behind it doesn't take the press (the
+ // selection drags); a filled layer above it still does.
+ let mut s = Session::new();
+ let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
+ let frame = id(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 200})));
+ for (parent, dx, where_) in [(None, 0.0, "on the canvas"), (Some(frame), 300.0, "in a top-level frame")] {
+ // `back` (0,0) filled, `front` (20,20) stroke only, `top` (60,60) filled; each 100×100, so
+ // the presses are well clear of the stroke.
+ let square = |s: &mut Session, x: f64| {
+ let mut p = json!({"x": x, "y": x, "width": 100, "height": 100});
+ if let Some(parent) = parent {
+ p["parent"] = json!(parent);
+ }
+ id(&ex(s, "shape.rectangle", p))
+ };
+ let back = square(&mut s, 0.0);
+ let front = square(&mut s, 20.0);
+ ex(&mut s, "node.set", json!({"ids": [front], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
+ let top = square(&mut s, 60.0);
+ // (drag from, selection after (the layer that moves by (10, 10)), derivation)
+ let rows = [
+ ((dx + 40.0, 40.0), front, "a filled layer behind the selected shape doesn't take the press: the selection drags"),
+ ((dx + 90.0, 90.0), top, "a filled layer above the selected shape still takes the press"),
+ ];
+ for (from, moved, why) in rows {
+ let home: Vec = [back, front, top].iter().map(|i| at(&s, *i)).collect();
+ ex(&mut s, "select.set", json!({"ids": [front]}));
+ drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE);
+ assert_eq!(s.selection(), vec![moved], "{why} ({where_})");
+ for (i, h) in [back, front, top].iter().zip(&home) {
+ let d = if *i == moved { 10.0 } else { 0.0 };
+ assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why} ({where_})");
+ }
+ assert!(s.undo().unwrap(), "{why}: one undo step");
+ }
+ }
+ // A filled layer below two selected shapes, in the gap between them (inside the selection's
+ // box but neither shape's), is still hit there.
+ let rect_at = |s: &mut Session, x: f64, w: f64| id(&ex(s, "shape.rectangle", json!({"x": x, "y": 400, "width": w, "height": 100})));
+ let gap = rect_at(&mut s, 720.0, 60.0);
+ let (a, b) = (rect_at(&mut s, 600.0, 100.0), rect_at(&mut s, 800.0, 100.0));
+ ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
+ ex(&mut s, "select.set", json!({"ids": [a, b]}));
+ let home = at(&s, gap);
+ drag(&mut s, (750.0, 450.0), (760.0, 460.0), NONE);
+ assert_eq!(s.selection(), vec![gap], "a layer in the gap between selected layers takes the press");
+ assert_eq!(at(&s, gap), Point::new(home.x + 10.0, home.y + 10.0));
+}
+
#[test]
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
let mut s = Session::new();
diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs
index 92a43306..8b33e303 100644
--- a/crates/engine/src/text_behaviour_tests.rs
+++ b/crates/engine/src/text_behaviour_tests.rs
@@ -310,3 +310,63 @@ fn new_text_starts_in_the_builds_default_font() {
assert_eq!(serde_json::to_value(td).unwrap()["style"]["font_family"], figcraft_doc::DEFAULT_FONT);
}
}
+
+fn resize_mode(s: &Session, t: NodeId) -> figcraft_doc::TextAutoResize {
+ s.doc().unwrap().node(t).unwrap().kind.text().unwrap().auto_resize
+}
+
+#[test]
+fn a_truncated_text_keeps_truncating_when_its_box_is_resized() {
+ // #1376: a truncating text is already fixed both ways, so an explicit size changes its box only.
+ use figcraft_doc::TextAutoResize;
+ let mut s = Session::new();
+ let t = text(&mut s, "Lorem consectetur", json!({"textAutoResize": "truncate", "width": 100, "height": 24}));
+ assert_eq!(resize_mode(&s, t), TextAutoResize::Truncate);
+ for (cmd, p, why) in [
+ ("node.set", json!({"id": t, "props": {"height": 24}}), "node.set height (the value it has)"),
+ ("node.set", json!({"id": t, "props": {"width": 120, "height": 30}}), "node.set width and height"),
+ ("transform.resize", json!({"ids": [t], "height": 40}), "transform.resize height"),
+ ("transform.resize", json!({"ids": [t], "width": 90}), "transform.resize width"),
+ ("node.set", json!({"id": t, "props": {"sizingV": "fixed"}}), "fixed vertical sizing"),
+ ] {
+ s.execute(cmd, &p).unwrap();
+ assert_eq!(resize_mode(&s, t), TextAutoResize::Truncate, "{why}");
+ }
+ // Text that doesn't truncate still turns fixed size.
+ let plain = text(&mut s, "Lorem consectetur", json!({"textAutoResize": "height", "width": 100}));
+ s.execute("node.set", &json!({"id": plain, "props": {"height": 24}})).unwrap();
+ assert_eq!(resize_mode(&s, plain), TextAutoResize::None);
+}
+
+#[test]
+fn max_lines_on_auto_height_text_hugs_that_many_lines() {
+ // #1377: an auto-height text with max lines grows up to that many lines and ends in an
+ // ellipsis; the layer reports its max lines.
+ use figcraft_doc::TextAutoResize;
+ figcraft_text::scan_system_fonts();
+ let mut s = Session::new();
+ let t = text(&mut s, "Lorem ipsum dolor sit amet consectetur", json!({"fontSize": 16, "lineHeight": 24}));
+ s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 100}})).unwrap();
+ assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("maxLines").is_none(), "no limit: not reported");
+ // Wrapping needs a font (CI may have none).
+ if size(&s, t).1 < 48.0 {
+ return;
+ }
+ s.execute("text.maxLines", &json!({"ids": [t], "lines": 1})).unwrap();
+ assert_eq!(resize_mode(&s, t), TextAutoResize::Height, "max lines keep auto height");
+ assert_eq!(size(&s, t), (100.0, 24.0), "one line tall");
+ assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["maxLines"], json!(1));
+ s.execute("node.set", &json!({"id": t, "props": {"maxLines": 2}})).unwrap();
+ assert_eq!(size(&s, t).1, 48.0, "two lines tall");
+ // No limit: it grows to all of its lines again.
+ s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap();
+ assert!(size(&s, t).1 > 48.0);
+ // An explicit height on a text that truncates makes a truncating box.
+ s.execute("node.set", &json!({"id": t, "props": {"maxLines": 1}})).unwrap();
+ s.execute("node.set", &json!({"id": t, "props": {"height": 30}})).unwrap();
+ assert_eq!((resize_mode(&s, t), size(&s, t).1), (TextAutoResize::Truncate, 30.0));
+ // Other text still becomes a truncating box with max lines.
+ let fixed = text(&mut s, "Lorem", json!({"textAutoResize": "none", "width": 100, "height": 100}));
+ s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap();
+ assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
+}
diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs
index 4ab6c50e..f9d549e4 100644
--- a/crates/engine/src/textfit.rs
+++ b/crates/engine/src/textfit.rs
@@ -29,6 +29,8 @@ pub fn specs() -> Vec {
/// paragraph of its own after a hard break, which no size can fix).
fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) {
let mut t = t.clone();
+ // Every line counts, not only those the layer's own line limit shows.
+ t.max_lines = 0;
let n = t.char_count();
t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0));
let l = figcraft_text::layout(&t, Some(width), None);
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
index 629c9a06..424694b3 100644
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -747,6 +747,26 @@ pub fn locked_in(doc: &Document, id: NodeId) -> bool {
std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked))
}
+/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the
+/// nearest layer holding both.
+fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool {
+ let chain = |id: NodeId| {
+ let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect();
+ c.reverse();
+ c
+ };
+ let (ct, cs) = (chain(t), chain(s));
+ let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count();
+ match (ct.get(shared), cs.get(shared)) {
+ (None, Some(_)) => true,
+ (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| {
+ let order: Vec = p.paint_order().collect();
+ order.iter().position(|c| c == a) < order.iter().position(|c| c == b)
+ }),
+ _ => false,
+ }
+}
+
/// Which handle (if any) is under `p`. A selection with a locked layer has none.
pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option {
if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) {
@@ -1363,8 +1383,12 @@ impl Session {
.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))
});
// A press inside the selection's box that reaches nothing above it (an unfilled shape's
- // inside, or the frame or canvas behind it) drags the selection.
- let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s)));
+ // inside, the frame or canvas behind it, or a layer stacked below the selected layer the
+ // press is in) drags the selection. In a gap between selected layers, what's there is hit.
+ let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p()));
+ let behind_sel = |t: Option| {
+ t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s)))))
+ };
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
Some(first)
@@ -1821,10 +1845,11 @@ impl Session {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
- t.auto_resize = match handle {
- Handle::Edge(1) | Handle::Edge(3) => TextAutoResize::Height,
- _ => TextAutoResize::None,
- };
+ match handle {
+ Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {}
+ Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height,
+ _ => t.fix_size(true, true),
+ }
}
return Ok(());
}
diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs
index 5eea5b30..e8a0239e 100644
--- a/crates/engine/src/variables_io_tests.rs
+++ b/crates/engine/src/variables_io_tests.rs
@@ -1,5 +1,5 @@
//! Extended variable collections (overrides, library updates, reset), variables import and
-//! export (JSON round trip, merging by name, CSS, junk), gradient stops bound to variables and
+//! export (JSON round trip, merging by name, CSS, Dart, junk), gradient stops bound to variables and
//! variant properties driven by string variables.
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint, PaintKind};
@@ -372,6 +372,86 @@ fn variant_properties_follow_string_variables() {
healthy(&s);
}
+#[test]
+fn variables_export_as_dart_constants_for_flutter() {
+ let mut s = Session::new();
+ theme(&mut s);
+ let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string();
+ for want in [
+ "import 'dart:ui';\n",
+ "/// Theme · Light\nabstract final class Theme {\n",
+ " static const Color colorPrimary = Color(0x800000FF);\n",
+ " static const double radius = 4;\n",
+ " static const String label = 'Say \"hi\"';\n",
+ " static const bool on = false;\n",
+ " static const Color accent = Color(0xFF000000);\n",
+ "/// Theme · Dark\nabstract final class ThemeDark {\n",
+ " static const double radius = 6.5;\n",
+ // An alias is its target's value in that mode.
+ " static const Color accent = Color(0xFFFFFFFF);\n",
+ ] {
+ assert!(dart.contains(want), "{want:?} in\n{dart}");
+ }
+ // Every class lists every variable once.
+ assert_eq!(dart.matches("static const").count(), 10, "{dart}");
+}
+
+#[test]
+fn dart_export_names_are_valid_dart() {
+ let mut s = Session::new();
+ ex(&mut s, "variable.createCollection", json!({"name": "2 brand-tokens", "modes": ["Default", "high contrast"]}));
+ ex(&mut s, "variable.createCollection", json!({"name": "Brand tokens"}));
+ for name in ["space/2xl", "class", "a b", "a-b", "\u{65e5}\u{672c}", "it's $5\\"] {
+ ex(&mut s, "variable.create", json!({"collection": "2 brand-tokens", "name": name, "type": "string", "value": name}));
+ }
+ let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string();
+ for want in [
+ "abstract final class V2BrandTokens {",
+ "abstract final class V2BrandTokensHighContrast {",
+ "abstract final class BrandTokens {",
+ "static const String space2xl = 'space/2xl';",
+ "static const String class_ = 'class';",
+ // Two names that read the same in Dart stay apart.
+ "static const String aB = 'a b';",
+ "static const String aB2 = 'a-b';",
+ // Nothing usable in a name: still a name.
+ "static const String v = '\u{65e5}\u{672c}';",
+ "static const String itS5 = 'it\\'s \\$5\\\\';",
+ ] {
+ assert!(dart.contains(want), "{want:?} in\n{dart}");
+ }
+}
+
+#[test]
+fn dart_export_names_never_shadow_dart_types() {
+ let mut s = Session::new();
+ ex(&mut s, "variable.createCollection", json!({"name": "Color"}));
+ ex(&mut s, "variable.createCollection", json!({"name": "String"}));
+ for name in ["double", "int", "bool", "num"] {
+ ex(&mut s, "variable.create", json!({"collection": "Color", "name": name, "type": "number", "value": 1}));
+ }
+ ex(&mut s, "variable.create", json!({"collection": "Color", "name": "ratio", "type": "number", "value": 0.125}));
+ ex(&mut s, "variable.create", json!({"collection": "Color", "name": "third", "type": "number", "value": 1.0 / 3.0}));
+ ex(&mut s, "variable.create", json!({"collection": "Color", "name": "huge", "type": "number", "value": 1.5e21}));
+ let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string();
+ // A class `Color` would hide dart:ui's `Color`, a constant `double` the type of those after it.
+ for want in [
+ "abstract final class Color_ {",
+ "abstract final class String_ {",
+ "static const double double_ = 1;",
+ "static const double int_ = 1;",
+ "static const double bool_ = 1;",
+ "static const double num_ = 1;",
+ // Numbers keep their full precision.
+ "static const double ratio = 0.125;",
+ "static const double third = 0.3333333333333333;",
+ // Too big for an exact integer literal: an exponent, still a double.
+ "static const double huge = 1.5e21;",
+ ] {
+ assert!(dart.contains(want), "{want:?} in\n{dart}");
+ }
+}
+
#[test]
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
let mut s = Session::new();
diff --git a/crates/engine/src/vector_drawable_export_tests.rs b/crates/engine/src/vector_drawable_export_tests.rs
new file mode 100644
index 00000000..4be39980
--- /dev/null
+++ b/crates/engine/src/vector_drawable_export_tests.rs
@@ -0,0 +1,108 @@
+//! Android Vector Drawable (XML) export through the commands: one-off exports with their
+//! warnings, export settings with a compatibility mode (stored, saved, edited), export layers,
+//! and bad values refused.
+
+use base64::Engine as _;
+use figcraft_doc::*;
+use serde_json::{Value, json};
+
+use crate::Session;
+use crate::params::id_of;
+
+/// A session with a 48 × 24 frame holding two overlapping rectangles at 50% opacity.
+fn session() -> (Session, NodeId) {
+ let mut s = Session::new();
+ let r = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 48, "height": 24, "name": "Badge"})).unwrap();
+ let f = id_of(&r["id"]).unwrap();
+ for x in [0, 12] {
+ s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 24, "height": 24, "parent": f})).unwrap();
+ }
+ s.execute("node.set", &json!({"ids": [f], "props": {"opacity": 0.5}})).unwrap();
+ s.set_selection(vec![f]);
+ (s, f)
+}
+
+fn xml(r: &Value) -> String {
+ let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
+ String::from_utf8(bytes).unwrap()
+}
+
+#[test]
+fn a_one_off_export_returns_the_drawable_and_its_warnings() {
+ let (mut s, f) = session();
+ let r = s.execute("file.export", &json!({"ids": [f], "format": "xml"})).unwrap();
+ assert_eq!(r["format"], "xml");
+ let x = xml(&r);
+ assert!(x.starts_with("`, a
+//! `\n\n\n"), "{out}");
+ // A text property is an interpolation, not React's braced expression.
+ assert!(out.contains("{{ title }}"), "{out}");
+}
+
+#[test]
+fn a_boolean_property_guards_its_layer_with_v_if() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let out = vue(&mut s, c);
+ // Vue can't wrap an element in an expression: the guard rides on the element itself.
+ assert!(out.contains("v-if=\"showDot\""), "{out}");
+ assert!(!out.contains("showDot &&"), "{out}");
+}
+
+#[test]
+fn variants_switch_on_data_attributes() {
+ let mut s = Session::new();
+ let mut variants = Vec::new();
+ for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() {
+ let f =
+ idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")})));
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}}));
+ variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
+ }
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
+ let out = vue(&mut s, set);
+ // A variant property is a string union, bound to the root's data attribute.
+ assert!(out.contains("state?: \"Default\" | \"Hover\";"), "{out}");
+ assert!(out.contains(":data-state=\"state\""), "{out}");
+ // What the other variant changes is a rule on that attribute, as on the web.
+ assert!(out.contains("[data-state=\"Hover\"]"), "{out}");
+}
+
+#[test]
+fn instances_are_calls_passing_the_values_they_set() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
+ // Values left at the definition's default aren't passed.
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n");
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
+ // A plain string is an attribute value; anything else is a `:name` binding.
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n");
+ let out = code(&mut s, i, "vue");
+ assert!(out.starts_with("\n"), "{out}");
+ assert!(out.contains("defineProps<{"), "{out}");
+}
+
+#[test]
+fn a_layer_tree_with_an_instance_calls_it() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let parent = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 500, "height": 300, "name": "Page"})));
+ ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 10, "parent": parent}));
+ let out = vue(&mut s, parent);
+ assert!(out.contains(""), "{out}");
+ assert!(!out.contains("content?: "), "{out}");
+ assert!(!out.contains("props"), "{out}");
+
+ // Content in the slot is its fallback, shown until an instance fills it.
+ let mut s = Session::new();
+ let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Shell2"})));
+ let body = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": frame, "name": "Body2"})));
+ ex(&mut s, "text.create", json!({"text": "Empty", "x": 0, "y": 0, "parent": body, "props": {"name": "Hint"}}));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
+ ex(&mut s, "component.makeSlot", json!({"id": body, "name": "Content"}));
+ let out = vue(&mut s, c);
+ assert!(out.contains(""), "{out}");
+ assert!(out.contains(""), "{out}");
+ assert!(out.contains("Empty"), "{out}");
+}
+
+#[test]
+fn identifiers_are_escaped_and_the_language_round_trips() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ // "Show dot" is not an identifier: it becomes camelCase in the props and the template alike.
+ let out = vue(&mut s, c);
+ assert!(out.contains("showDot"), "{out}");
+ assert!(!out.contains("Show dot"), "{out}");
+ // The id is discoverable from what the Inspect panel offers.
+ assert_eq!(CodeLang::from_id("vue"), Some(CodeLang::Vue));
+ assert_eq!(CodeLang::from_id("sfc"), Some(CodeLang::Vue));
+ assert!(CodeLang::Vue.is_web());
+ assert_eq!(CodeLang::Vue.id(), "vue");
+ assert!(CodeLang::ALL.contains(&CodeLang::Vue));
+}
+
+#[test]
+fn rem_converts_lengths_in_the_vue_template_and_its_style() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 64, "name": "Bar"})));
+ let out = ex(&mut s, "inspect.code", json!({"id": f, "language": "vue", "unit": "rem"}))["code"].as_str().unwrap_or_default().to_string();
+ assert!(out.contains("20rem"), "{out}");
+}
+
+#[test]
+fn a_keyword_property_name_stays_usable() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Row"})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ // "default" is a JavaScript keyword: the prop is renamed, and the template follows.
+ ex(&mut s, "component.addProperty", json!({"id": c, "name": "default", "type": "text", "default": "x"}));
+ let out = vue(&mut s, c);
+ assert!(out.contains("defaultValue"), "{out}");
+}
+
+#[test]
+fn a_property_named_props_is_not_shadowed() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
+ let text = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(&mut s, "component.addProperty", json!({"id": c, "name": "Props", "type": "text", "layer": text}));
+ let out = vue(&mut s, c);
+ // No top-level `const props` that the template would resolve to instead of the property.
+ assert!(!out.contains("const props"), "{out}");
+ assert!(out.contains("props?: string;"), "{out}");
+ assert!(out.contains("{{ props }}"), "{out}");
+}
+
+#[test]
+fn a_nested_instance_keeps_its_v_if_guard() {
+ let mut s = Session::new();
+ let (card, _, _) = card(&mut s);
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Toolbar"})));
+ ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
+ let nested = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 10, "y": 10, "parent": f})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show card", "type": "boolean", "default": true, "layer": nested}));
+ ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Title": "a > b"}}));
+ let out = vue(&mut s, c);
+ // The call's early return must not swallow the guard `node` put on it, and the `>` inside a
+ // quoted value must not be mistaken for the tag's end.
+ assert!(out.contains("v-if=\"showCard\""), "{out}");
+ assert_eq!(out.matches("v-if=\"showCard\"").count(), 1, "{out}");
+ assert!(!out.contains("/ v-if"), "the guard must come before a self-closing slash: {out}");
+ assert!(out.contains("title=\"a > b\"") || out.contains("title=\"a > b\""), "{out}");
+}
+
+#[test]
+fn a_text_default_cannot_close_the_script_block() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
+ let text = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(
+ &mut s,
+ "component.addProperty",
+ json!({"id": c, "name": "Title", "type": "text", "layer": text, "default": ""}),
+ );
+ let out = vue(&mut s, c);
+ assert!(out.contains("\\u003C/script>"), "{out}");
+ assert_eq!(out.matches("").count(), 1, "only the SFC's own closing tag: {out}");
+}
+
+#[test]
+fn slot_names_stay_distinct_when_idents_lowercase_alike() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Shell"})));
+ let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": f, "name": "A"})));
+ let b = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100, "parent": f, "name": "B"})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(&mut s, "component.makeSlot", json!({"id": a, "name": "Foo Bar"}));
+ ex(&mut s, "component.makeSlot", json!({"id": b, "name": "Foobar"}));
+ let out = vue(&mut s, c);
+ assert!(out.contains(""), "{usage}");
+ assert!(usage.contains(""), "{usage}");
+}
+
+#[test]
+fn slot_content_css_reaches_the_parent_stylesheet() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
+ let content = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "name": "Content"})));
+ let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(&mut s, "component.makeSlot", json!({"id": content, "name": "Content"}));
+ let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 400, "height": 300, "name": "Screen"})));
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 10, "y": 10, "parent": screen})));
+ let slot = {
+ let doc = s.doc().unwrap();
+ crate::components::slots_of(doc, i).first().map(|(l, _)| *l).unwrap()
+ };
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": slot, "props": {"name": "Swatch", "fill": "#FF0000"}}));
+ // The layer's class rules belong to the tree that holds the instance, not to a discarded
+ // snippet: both web targets keep them in their stylesheet.
+ let out = vue(&mut s, screen);
+ assert!(out.contains(".slot-content-swatch {"), "{out}");
+ assert!(out.contains("#FF0000"), "{out}");
+ assert!(code(&mut s, screen, "react").contains(".slot-content-swatch {"));
+}
+
+#[test]
+fn slot_content_classes_are_unique_across_slots() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
+ let one = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": f, "name": "One"})));
+ let two = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100, "parent": f, "name": "Two"})));
+ let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ ex(&mut s, "component.makeSlot", json!({"id": one, "name": "One"}));
+ ex(&mut s, "component.makeSlot", json!({"id": two, "name": "Two"}));
+ let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 400, "height": 300, "name": "Screen"})));
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 10, "y": 10, "parent": screen})));
+ let slots = {
+ let doc = s.doc().unwrap();
+ crate::components::slots_of(doc, i)
+ };
+ for (name, fill) in [("One", "#FF0000"), ("Two", "#0000FF")] {
+ let slot = slots.iter().find(|(_, n)| n.as_str() == name).map(|(l, _)| *l).unwrap();
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": slot, "props": {"name": "Swatch", "fill": fill}}));
+ }
+ let out = vue(&mut s, screen);
+ // Same layer name in two slots: each slot's rules keep their own class.
+ assert!(out.contains(".slot-one-swatch {"), "{out}");
+ assert!(out.contains(".slot-two-swatch {"), "{out}");
+ assert!(out.contains("#FF0000"), "{out}");
+ assert!(out.contains("#0000FF"), "{out}");
+}
diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml
index 3dca3912..b144465f 100644
--- a/crates/format/Cargo.toml
+++ b/crates/format/Cargo.toml
@@ -16,5 +16,6 @@ serde_json = { workspace = true }
zip = { workspace = true }
flate2 = { workspace = true }
ruzstd = { workspace = true }
+sha1 = { workspace = true }
sha2 = { workspace = true }
image = { workspace = true }
diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs
index dad09d95..8343a4ff 100644
--- a/crates/format/src/fig/export.rs
+++ b/crates/format/src/fig/export.rs
@@ -31,6 +31,7 @@ use figcraft_doc::{
VariableValue, VectorNetwork, WindingRule,
};
use serde_json::Value;
+use sha1::Digest;
use super::kiwi::{Kv, Schema, encode};
use super::network;
@@ -104,7 +105,7 @@ enum ImageType PNG=0 JPEG=1 SVG=2 PDF=3
enum ExportConstraintType CONTENT_SCALE=0 CONTENT_WIDTH=1 CONTENT_HEIGHT=2
enum ComponentPropType BOOL=0 TEXT=1 INSTANCE_SWAP=3 VARIANT=4
enum VariableField MISSING=0 CORNER_RADIUS=1 PARAGRAPH_SPACING=2 PARAGRAPH_INDENT=3 STROKE_WEIGHT=4 STACK_SPACING=5 STACK_PADDING_LEFT=6 STACK_PADDING_TOP=7 STACK_PADDING_RIGHT=8 STACK_PADDING_BOTTOM=9 VISIBLE=10 TEXT_DATA=11 WIDTH=12 HEIGHT=13 RECTANGLE_TOP_LEFT_CORNER_RADIUS=14 RECTANGLE_TOP_RIGHT_CORNER_RADIUS=15 RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS=16 RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS=17 BORDER_TOP_WEIGHT=18 BORDER_BOTTOM_WEIGHT=19 BORDER_LEFT_WEIGHT=20 BORDER_RIGHT_WEIGHT=21 STACK_COUNTER_SPACING=23 MIN_WIDTH=24 MAX_WIDTH=25 MIN_HEIGHT=26 MAX_HEIGHT=27 FONT_FAMILY=28 FONT_STYLE=29 OPACITY=31 FONT_SIZE=32 LETTER_SPACING=34 LINE_HEIGHT=36 OVERRIDDEN_SYMBOL_ID=37
-enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 PROP_REF=13
+enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 SYMBOL_ID=7 TEXT_DATA=9 PROP_REF=13
enum VariableScope ALL_SCOPES=0 TEXT_CONTENT=1 CORNER_RADIUS=2 WIDTH_HEIGHT=3 GAP=4 ALL_FILLS=5 FRAME_FILL=6 SHAPE_FILL=7 TEXT_FILL=8 STROKE=9 STROKE_FLOAT=10 EFFECT_FLOAT=11 EFFECT_COLOR=12 OPACITY=13 FONT_STYLE=14 FONT_FAMILY=15 FONT_SIZE=16 LINE_HEIGHT=17 LETTER_SPACING=18 PARAGRAPH_SPACING=19 PARAGRAPH_INDENT=20
enum StyleType NONE=0 FILL=1 STROKE=2 TEXT=3 EFFECT=4 EXPORT=5 GRID=6
enum InteractionType ON_CLICK=0 DRAG=9 ON_HOVER=4 ON_PRESS=7 MOUSE_IN=2 MOUSE_OUT=3 MOUSE_DOWN=5 MOUSE_UP=6 AFTER_TIMEOUT=1 ON_KEY_DOWN=10
@@ -148,10 +149,16 @@ message ExportSettings string suffix=1, ImageType imageType=2, ExportConstraint
message GUIDPath GUID[] guids=1
message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2, float uniformScaleFactor=3
message ComponentPropValue bool boolValue=1, TextData textValue=2, GUID guidValue=3
-message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, ComponentPropType type=6
-message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2
+message ComponentPropPreferredValues string[] stringValues=1
+message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, string sortPosition=4, GUID parentPropDefId=5, ComponentPropType type=6, ComponentPropPreferredValues preferredValues=8, VariableData varValue=9
+message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2, VariableData varValue=3
+enum ComponentPropNodeField VISIBLE=0 TEXT_DATA=1 OVERRIDDEN_SYMBOL_ID=2
+message ComponentPropRef GUID defID=2, ComponentPropNodeField componentPropNodeField=4
+message VariantPropSpec GUID propDefId=1, string value=2
+message StateGroupPropertyValueOrder string property=1, string[] values=2
message PropRefValue GUID defId=1
-message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, PropRefValue propRefValue=13
+message SymbolId GUID guid=1
+message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, SymbolId symbolIdValue=8, TextData textDataValue=10, PropRefValue propRefValue=13
message VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3
message VariableDataMapEntry uint nodeField=1, VariableData variableData=2, VariableField variableField=3
message VariableDataMap VariableDataMapEntry[] entries=1
@@ -169,11 +176,11 @@ message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Colo
message GUIDPositionMapEntry GUID id=1, string position=2
message GUIDPositionMap GUIDPositionMapEntry[] entries=1
enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3
-message GridTrackSizeFunction GridTrackSizingType type=1, float value=2
-message GridTrackSize GridTrackSizeFunction minSizing=1, GridTrackSizeFunction maxSizing=2
-message GridTrackSizeEntry GUID id=1, GridTrackSize trackSize=2
-message GridTrackSizeMap GridTrackSizeEntry[] entries=1
-message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GridTrackSizeMap gridColumnsSizing=443, GridTrackSizeMap gridRowsSizing=444, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
+message GridTrackSizingFunction GridTrackSizingType type=1, float value=2
+message GridTrackSize GridTrackSizingFunction minSizing=1, GridTrackSizingFunction maxSizing=2
+message GUIDGridTrackSizeMapEntry GUID id=1, GridTrackSize trackSize=2
+message GUIDGridTrackSizeMap GUIDGridTrackSizeMapEntry[] entries=1
+message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
struct Blob byte[] bytes
message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6
";
@@ -442,12 +449,18 @@ struct Exporter<'d> {
images: BTreeMap>,
/// `images/` members.
files: Vec<(String, Arc>)>,
- /// Component property (name, type) → local id of its definition's GUID (session [`AUX`]).
- defs: BTreeMap<(String, &'static str), u32>,
+ /// Component property (owner, name, type) → local id of its definition's GUID (session [`AUX`]).
+ /// The owner is the component, or the set for a variant (variant properties included, type
+ /// `VARIANT`).
+ defs: BTreeMap<(NodeId, String, &'static str), u32>,
+ /// A variant's own definition of each of its set's properties (variant, name, type) → local
+ /// id (session [`AUX`]): what its layers and its instances refer to.
+ child_defs: BTreeMap<(NodeId, String, &'static str), u32>,
/// Grid tracks of written grid frames: (columns, rows) local ids (session [`AUX`]).
tracks: BTreeMap, Vec)>,
aux: u32,
overlays: BTreeMap,
+ trigger_size: [f64; 2],
/// Flow starting points: layer → (order, name).
starts: BTreeMap,
/// Shared styles, variable collections and variables → local id of their node's GUID (session
@@ -483,9 +496,11 @@ impl<'d> Exporter<'d> {
images: BTreeMap::new(),
files: Vec::new(),
defs: BTreeMap::new(),
+ child_defs: BTreeMap::new(),
tracks: BTreeMap::new(),
aux: 0,
overlays: BTreeMap::new(),
+ trigger_size: [0.0, 0.0],
starts: BTreeMap::new(),
style_ids: BTreeMap::new(),
set_ids: BTreeMap::new(),
@@ -510,6 +525,54 @@ impl<'d> Exporter<'d> {
self.ids.get(&id).map(|l| guid(LAYERS, *l))
}
+ /// The component set component `id` is a variant of.
+ fn set_of(&self, id: NodeId) -> Option {
+ let parent = self.doc.node(id)?.parent.and_then(|p| self.doc.node(p))?;
+ matches!(parent.kind, NodeKind::ComponentSet { .. }).then_some(parent.id)
+ }
+
+ /// The local id a layer of component `comp` (or an instance of it) uses for its property
+ /// `name` of type `ty`: a variant's own definition, else the component's.
+ fn def_for(&self, comp: NodeId, name: &str, ty: &'static str) -> Option {
+ let key = (comp, name.to_string(), ty);
+ let defs = if self.set_of(comp).is_some() { &self.child_defs } else { &self.defs };
+ defs.get(&key).copied()
+ }
+
+ /// The main component a layer belongs to (itself when it is one). A layer inside an instance
+ /// mirrors its main's layer (`source`, through nested instances): that layer's component, so
+ /// the instance's layers refer to what the main's do. An instance placed in a component is the
+ /// component's own layer (its `source` is its main, which it doesn't belong to).
+ fn component_of(&self, n: &Node) -> Option {
+ let in_instance = |x: &Node| {
+ let mut p = x.parent.and_then(|p| self.doc.node(p));
+ for _ in 0..MAX_DEPTH {
+ match p {
+ Some(a) if matches!(a.kind, NodeKind::Instance { .. }) => return true,
+ Some(a) => p = a.parent.and_then(|q| self.doc.node(q)),
+ None => return false,
+ }
+ }
+ false
+ };
+ let mut n = n;
+ for _ in 0..MAX_DEPTH {
+ match n.source.and_then(|s| self.doc.node(s)) {
+ Some(src) if in_instance(n) => n = src,
+ _ => break,
+ }
+ }
+ let mut cur = Some(n);
+ for _ in 0..MAX_DEPTH {
+ let c = cur?;
+ if matches!(c.kind, NodeKind::Component { .. }) {
+ return Some(c.id);
+ }
+ cur = c.parent.and_then(|p| self.doc.node(p));
+ }
+ None
+ }
+
/// The message: the document, its pages and their layers, and the blobs.
fn message(&mut self) -> Kv<'static> {
let doc = self.doc;
@@ -556,12 +619,27 @@ impl<'d> Exporter<'d> {
}
match &n.kind {
NodeKind::Component { props, .. } => {
+ let set = self.set_of(id);
+ let owner = set.unwrap_or(id);
for (name, d) in props {
if let Some(ty) = prop_type(d) {
- let key = (name.clone(), ty);
+ let key = (owner, name.clone(), ty);
if !self.defs.contains_key(&key) {
- let id = self.next_aux();
- self.defs.insert(key, id);
+ let def = self.next_aux();
+ self.defs.insert(key, def);
+ }
+ if set.is_some() {
+ let child = self.next_aux();
+ self.child_defs.insert((id, name.clone(), ty), child);
+ }
+ }
+ }
+ if let Some(set) = set {
+ for (prop, _) in variant_pairs(&n.name) {
+ let key = (set, prop, "VARIANT");
+ if !self.defs.contains_key(&key) {
+ let def = self.next_aux();
+ self.defs.insert(key, def);
}
}
}
@@ -574,8 +652,9 @@ impl<'d> Exporter<'d> {
_ => {}
}
for a in n.interactions.iter().flat_map(|it| figcraft_doc::proto::walk(&it.actions)) {
- if let Action::OpenOverlay { dest, position, close_on_outside, background, .. } = a {
- let o = Overlay { position: *position, close_on_outside: *close_on_outside, background: *background };
+ if let Action::OpenOverlay { dest, position, close_on_outside, background, anchor, .. } = a {
+ let position = if !anchor.is_screen() && *position != OverlayPosition::Manual { OverlayPosition::Manual } else { *position };
+ let o = Overlay { position, close_on_outside: *close_on_outside, background: *background };
match self.overlays.get(dest) {
Some(old) if (old.position, old.close_on_outside, old.background) != (o.position, o.close_on_outside, o.background) => {
self.warn("overlays opened with different settings keep the first one's");
@@ -985,9 +1064,36 @@ impl<'d> Exporter<'d> {
}
}
NodeKind::Component { description, props } if !nested => {
- let defs: Vec = props.iter().filter_map(|(name, d)| self.prop_def(name, d)).collect();
- if !defs.is_empty() {
- f.push(("componentPropDefs", arr(defs)));
+ if let Some(set) = self.set_of(n.id) {
+ // A variant: its set holds the definitions; the variant has its own copy of
+ // each (pointing at the set's), and its values of the variant properties.
+ let own: Vec = props
+ .iter()
+ .filter_map(|(name, d)| {
+ let ty = prop_type(d)?;
+ let id = *self.child_defs.get(&(n.id, name.clone(), ty))?;
+ let parent = *self.defs.get(&(set, name.clone(), ty))?;
+ Some(o(vec![("id", guid(AUX, id)), ("parentPropDefId", guid(AUX, parent))]))
+ })
+ .collect();
+ if !own.is_empty() {
+ f.push(("componentPropDefs", arr(own)));
+ }
+ let specs: Vec = variant_pairs(&n.name)
+ .into_iter()
+ .filter_map(|(prop, value)| {
+ let id = *self.defs.get(&(set, prop, "VARIANT"))?;
+ Some(o(vec![("propDefId", guid(AUX, id)), ("value", s(&value))]))
+ })
+ .collect();
+ if !specs.is_empty() {
+ f.push(("variantPropSpecs", arr(specs)));
+ }
+ } else {
+ let defs: Vec = props.iter().enumerate().filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i)).collect();
+ if !defs.is_empty() {
+ f.push(("componentPropDefs", arr(defs)));
+ }
}
if props.values().any(|d| matches!(d, PropDef::Slot { .. })) {
self.warn("slot properties were left out");
@@ -998,6 +1104,7 @@ impl<'d> Exporter<'d> {
}
NodeKind::ComponentSet { description } if !nested => {
f.push(("isStateGroup", b(true)));
+ self.set_props(n, f);
if !description.is_empty() {
f.push(("symbolDescription", s(description)));
}
@@ -1099,12 +1206,16 @@ impl<'d> Exporter<'d> {
if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::Webp) {
self.warn("WebP export settings were written as PNG");
}
+ if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::VectorDrawable) {
+ self.warn("Android Vector Drawable (XML) export settings were written as SVG");
+ }
f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect())));
}
self.prototype(n, f);
- let refs: Vec = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(field, prop)).collect();
- if !refs.is_empty() {
- f.push(("parameterConsumptionMap", o(vec![("entries", arr(refs))])));
+ let (entries, refs): (Vec, Vec) = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(n, field, prop)).unzip();
+ if !entries.is_empty() {
+ f.push(("parameterConsumptionMap", o(vec![("entries", arr(entries))])));
+ f.push(("componentPropRefs", arr(refs)));
}
let text = matches!(n.kind, NodeKind::Text(_));
let links = [
@@ -1254,7 +1365,7 @@ impl<'d> Exporter<'d> {
None => {
let h = match self.doc.images.get(key) {
Some(img) if img.bytes.len() as u64 <= MAX_IMAGE => {
- let h = crate::image_hash(&img.bytes);
+ let h = sha1_hex(&img.bytes);
if !self.files.iter().any(|(x, _)| *x == h) {
self.files.push((h.clone(), img.bytes.clone()));
}
@@ -1484,9 +1595,12 @@ impl<'d> Exporter<'d> {
/// and are written whatever its parent is.
fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) {
let cl = &n.child_layout;
- for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
+ // An axis without a minimum is 0; one without a maximum is infinite (0 would be a maximum
+ // of 0, which collapses the layer in readers that apply it).
+ for (key, w, h, none) in [("minSize", cl.min_w, cl.min_h, 0.0), ("maxSize", cl.max_w, cl.max_h, f32::INFINITY)] {
if w.is_some() || h.is_some() {
- f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
+ let axis = |x: Option| x.map_or(Kv::Float(none), fl);
+ f.push((key, o(vec![("value", o(vec![("x", axis(w)), ("y", axis(h))]))])));
}
}
let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return };
@@ -1555,7 +1669,7 @@ impl<'d> Exporter<'d> {
("textTracking", fl(0.0)),
("emojiImageSet", e("APPLE")),
]);
- if td.auto_resize == TextAutoResize::Truncate {
+ if td.truncates() {
f.push(("textTruncation", e("ENDING")));
}
if td.max_lines > 0 {
@@ -1854,26 +1968,105 @@ impl<'d> Exporter<'d> {
Some(i)
}
- /// A component property definition (variant properties are implied by the variants' names).
- fn prop_def(&mut self, name: &str, d: &PropDef) -> Option> {
+ /// A component property definition of `owner` (a component, or a set), `index`-th in its list.
+ /// The default value is written twice: as `initialValue` and as `varValue`, which is what
+ /// readers that know variables take.
+ fn prop_def(&mut self, owner: NodeId, name: &str, d: &PropDef, index: usize) -> Option> {
let ty = prop_type(d)?;
- let id = *self.defs.get(&(name.to_string(), ty))?;
- let value = match d {
- PropDef::Boolean { default } => o(vec![("boolValue", b(*default))]),
- PropDef::Text { default } => o(vec![("textValue", o(vec![("characters", s(default))]))]),
+ let id = *self.defs.get(&(owner, name.to_string(), ty))?;
+ let (value, var) = match d {
+ PropDef::Boolean { default } => (o(vec![("boolValue", b(*default))]), bool_data(*default)),
+ PropDef::Text { default } => (o(vec![("textValue", text_value(default))]), text_data(default)),
PropDef::InstanceSwap { default, preferred } => {
if !preferred.is_empty() {
self.warn("preferred values of instance swap properties were left out");
}
- o(vec![("guidValue", self.guid_of(*default)?)])
+ let main = self.guid_of(*default)?;
+ (o(vec![("guidValue", main.clone())]), symbol_data(main))
}
PropDef::Variant { .. } | PropDef::Slot { .. } => return None,
};
- Some(o(vec![("id", guid(AUX, id)), ("name", s(name)), ("initialValue", value), ("type", e(ty))]))
+ Some(o(vec![
+ ("id", guid(AUX, id)),
+ ("name", s(name)),
+ ("initialValue", value),
+ ("sortPosition", s(&sort_position(index))),
+ ("type", e(ty)),
+ ("preferredValues", o(Vec::new())),
+ ("varValue", var),
+ ]))
}
- /// Which property controls a layer's `field`.
- fn prop_ref(&mut self, field: &str, prop: &str) -> Option> {
+ /// A component set's property definitions: its variant properties (from the variants' names,
+ /// in the order they first appear, with their values in order) and the other properties its
+ /// variants share; written on the set, as the format keeps them.
+ fn set_props(&mut self, set: &Node, f: &mut Fields) {
+ let doc = self.doc;
+ let variants: Vec<&Node> =
+ set.children.iter().filter_map(|c| doc.node(*c)).filter(|c| matches!(c.kind, NodeKind::Component { .. })).collect();
+ let mut order: Vec<(String, Vec)> = Vec::new();
+ for v in &variants {
+ for (prop, value) in variant_pairs(&v.name) {
+ let i = match order.iter().position(|(p, _)| *p == prop) {
+ Some(i) => i,
+ None => {
+ order.push((prop, Vec::new()));
+ order.len() - 1
+ }
+ };
+ if let Some((_, values)) = order.get_mut(i)
+ && !values.contains(&value)
+ {
+ values.push(value);
+ }
+ }
+ }
+ let mut defs = Vec::new();
+ for (prop, _) in &order {
+ if let Some(&id) = self.defs.get(&(set.id, prop.clone(), "VARIANT")) {
+ defs.push(o(vec![
+ ("id", guid(AUX, id)),
+ ("name", s(prop)),
+ ("initialValue", o(Vec::new())),
+ ("sortPosition", s(&sort_position(defs.len()))),
+ ("type", e("VARIANT")),
+ ("preferredValues", o(Vec::new())),
+ ("varValue", var_data(("textValue", s("")), "STRING", "STRING")),
+ ]));
+ }
+ }
+ // The format keeps one default per property of a set (a variant's copy holds none): the first
+ // variant's, with a warning when another variant's differs.
+ // A name used with two types is two properties (their definitions have their own ids).
+ let mut seen: BTreeMap<(String, &'static str), PropDef> = BTreeMap::new();
+ for v in &variants {
+ let NodeKind::Component { props, .. } = &v.kind else { continue };
+ for (name, d) in props {
+ let Some(ty) = prop_type(d) else { continue };
+ match seen.get(&(name.clone(), ty)) {
+ Some(first) if first != d => self.warn("variants with different defaults for one property were written with the first variant's"),
+ Some(_) => {}
+ None => {
+ seen.insert((name.clone(), ty), d.clone());
+ let index = defs.len();
+ defs.extend(self.prop_def(set.id, name, d, index));
+ }
+ }
+ }
+ }
+ if !defs.is_empty() {
+ f.push(("componentPropDefs", arr(defs)));
+ }
+ if !order.is_empty() {
+ let orders = order.into_iter().map(|(p, values)| o(vec![("property", s(&p)), ("values", arr(values.iter().map(|x| s(x)).collect()))]));
+ f.push(("stateGroupPropertyValueOrders", arr(orders.collect())));
+ }
+ }
+
+ /// Which property controls a layer's `field`: the entry of its parameter consumption map and
+ /// its component property reference (readers take one or the other), both naming the
+ /// definition of the component the layer is in (a variant's own copy, in a set).
+ fn prop_ref(&mut self, n: &Node, field: &str, prop: &str) -> Option<(Kv<'static>, Kv<'static>)> {
let (var_field, ty, resolved) = match field {
"visible" => ("VISIBLE", "BOOL", "BOOLEAN"),
"characters" => ("TEXT_DATA", "TEXT", "TEXT_DATA"),
@@ -1883,19 +2076,24 @@ impl<'d> Exporter<'d> {
return None;
}
};
- let id = *self.defs.get(&(prop.to_string(), ty))?;
+ let id = self.def_for(self.component_of(n)?, prop, ty)?;
let data = o(vec![
("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])),
("dataType", e("PROP_REF")),
("resolvedDataType", e(resolved)),
]);
- Some(o(vec![("variableData", data), ("variableField", e(var_field))]))
+ let entry = o(vec![("variableData", data), ("variableField", e(var_field))]);
+ let reference = o(vec![("defID", guid(AUX, id)), ("componentPropNodeField", e(var_field))]);
+ Some((entry, reference))
}
/// An instance: its main, the fields it and its layers override, the size, place and outline
/// its layers get from that (derived data), its property values. `f`: its own fields so far.
fn instance(&mut self, n: &Node, main: NodeId, props: &BTreeMap, f: &mut Fields) {
- const OWN: [&str; 7] = ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry"];
+ // Which properties control the instance itself (a nested instance in a main component) is
+ // its own too, not an override of its main.
+ const OWN: [&str; 9] =
+ ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry", "parameterConsumptionMap", "componentPropRefs"];
let doc = self.doc;
let (Some(m), Some(symbol)) = (doc.node(main), self.guid_of(main)) else { return };
// What the instance itself changes from its main (its size, its paints, …) is an override
@@ -1999,15 +2197,19 @@ impl<'d> Exporter<'d> {
}
let mut assignments = Vec::new();
for (name, v) in props {
- let (ty, value) = match v {
- Value::Bool(x) => ("BOOL", Some(o(vec![("boolValue", b(*x))]))),
- Value::String(t) => ("TEXT", Some(o(vec![("textValue", o(vec![("characters", s(t))]))]))),
- Value::Number(c) => ("INSTANCE_SWAP", c.as_u64().and_then(|c| self.guid_of(NodeId(c))).map(|g| o(vec![("guidValue", g)]))),
+ let (ty, value, var) = match v {
+ Value::Bool(x) => ("BOOL", o(vec![("boolValue", b(*x))]), bool_data(*x)),
+ Value::String(t) => ("TEXT", o(vec![("textValue", text_value(t))]), text_data(t)),
+ Value::Number(c) => {
+ let Some(g) = c.as_u64().and_then(|c| self.guid_of(NodeId(c))) else { continue };
+ ("INSTANCE_SWAP", o(vec![("guidValue", g.clone())]), symbol_data(g))
+ }
_ => continue,
};
- // Variant values have no definition of their own: the main they show says them.
- if let (Some(&id), Some(value)) = (self.defs.get(&(name.clone(), ty)), value) {
- assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value)]));
+ // Variant values have no definition of their own: the main they show says them. A
+ // variant's instance names the variant's own copy of the definition.
+ if let Some(id) = self.def_for(main, name, ty) {
+ assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value), ("varValue", var)]));
}
}
if !assignments.is_empty() {
@@ -2086,6 +2288,7 @@ impl<'d> Exporter<'d> {
/// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.
fn prototype(&mut self, n: &Node, f: &mut Fields) {
+ self.trigger_size = [n.width, n.height];
let list: Vec = n.interactions.iter().filter_map(|it| self.interaction(it)).collect();
if !list.is_empty() {
f.push(("prototypeInteractions", arr(list)));
@@ -2174,10 +2377,16 @@ impl<'d> Exporter<'d> {
}
Some(act)
}
- Action::OpenOverlay { dest, position, offset, .. } => {
+ Action::OpenOverlay { dest, position, offset, anchor, .. } => {
let mut act = node(self, dest, "OVERLAY")?;
- if let (Kv::Object(f), OverlayPosition::Manual, Some([x, y])) = (&mut act, position, offset) {
- f.push(("overlayRelativePosition", o(vec![("x", fl(*x)), ("y", fl(*y))])));
+ let size = self.doc.node(*dest).map_or([0.0, 0.0], |d| [d.width, d.height]);
+ let offset = match anchor.offset(*position, self.trigger_size, size) {
+ Some(o) => Some(o),
+ None if *position == OverlayPosition::Manual => *offset,
+ None => None,
+ };
+ if let (Kv::Object(f), Some([x, y])) = (&mut act, offset) {
+ f.push(("overlayRelativePosition", o(vec![("x", fl(x)), ("y", fl(y))])));
}
Some(act)
}
@@ -2433,7 +2642,9 @@ fn unset(key: &str) -> Option> {
"cornerSmoothing" | "stackChildPrimaryGrow" => fl(0.0),
"stackMode" => e("NONE"),
"stackChildAlignSelf" | "stackPositioning" => e("AUTO"),
- "minSize" | "maxSize" => o(vec![("value", xy(0.0, 0.0))]),
+ "minSize" => o(vec![("value", xy(0.0, 0.0))]),
+ // No maximum is infinite (a maximum of 0 collapses the layer).
+ "maxSize" => o(vec![("value", o(vec![("x", Kv::Float(f32::INFINITY)), ("y", Kv::Float(f32::INFINITY))]))]),
"gridColumnSpan" | "gridRowSpan" | "maxLines" => int(0),
"scrollDirection" => e("NONE"),
"scrollBehavior" => e("SCROLLS"),
@@ -2444,6 +2655,74 @@ fn unset(key: &str) -> Option> {
})
}
+/// An image's name in the file: the SHA-1 of its bytes (lowercase hex). Paints refer to images by
+/// these 20 bytes, and readers reject other hash lengths (the document's own SHA-256 key is not
+/// used here).
+pub(super) fn sha1_hex(bytes: &[u8]) -> String {
+ sha1::Sha1::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// A style name as readers look the face up: the standard name ([`super::style_name`]) with the
+/// compound weights spelt the way most font families name their faces ("SemiBold", "ExtraBold",
+/// "ExtraLight Italic"), except Inter, whose faces are named with a space ("Semi Bold"). A name
+/// the family doesn't have reads as a missing font, and its text isn't drawn.
+pub(super) fn face_style(family: &str, style: &str) -> String {
+ let standard = super::style_name(style);
+ if family == "Inter" || family.starts_with("Inter ") {
+ return standard;
+ }
+ ["Extra", "Ultra", "Semi", "Demi"].iter().fold(standard, |acc, p| acc.replace(&format!("{p} "), p))
+}
+
+/// The pairs of a variant's name, `Property=Value, …` (parts without `=` are left out).
+fn variant_pairs(name: &str) -> Vec<(String, String)> {
+ name.split(',')
+ .filter_map(|part| {
+ let (p, v) = part.split_once('=')?;
+ let (p, v) = (p.trim(), v.trim());
+ (!p.is_empty()).then(|| (p.to_string(), v.to_string()))
+ })
+ .collect()
+}
+
+/// The `index`-th sort position of a list: one printable character from `"` on, two past the end
+/// of that range.
+fn sort_position(index: usize) -> String {
+ const FIRST: u32 = 34;
+ const SPAN: usize = 93;
+ let ch = |i: usize| char::from_u32(FIRST + u32::try_from(i % SPAN).unwrap_or(0)).unwrap_or('"');
+ if index < SPAN { ch(index).to_string() } else { format!("~{}", ch(index - SPAN)) }
+}
+
+/// Text as a property value: its characters and one plain line per paragraph.
+fn text_value(t: &str) -> Kv<'static> {
+ let line = || {
+ o(vec![
+ ("lineType", e("PLAIN")),
+ ("styleId", int(0)),
+ ("indentationLevel", int(0)),
+ ("sourceDirectionality", e("AUTO")),
+ ("listStartOffset", int(0)),
+ ("isFirstLineOfList", b(false)),
+ ])
+ };
+ o(vec![("characters", s(t)), ("lines", arr(t.split('\n').map(|_| line()).collect()))])
+}
+
+/// A property value as variable data (`varValue`).
+fn var_data(value: (&'static str, Kv<'static>), ty: &'static str, resolved: &'static str) -> Kv<'static> {
+ o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))])
+}
+fn bool_data(x: bool) -> Kv<'static> {
+ var_data(("boolValue", b(x)), "BOOLEAN", "BOOLEAN")
+}
+fn text_data(t: &str) -> Kv<'static> {
+ var_data(("textDataValue", text_value(t)), "TEXT_DATA", "TEXT_DATA")
+}
+fn symbol_data(main: Kv<'static>) -> Kv<'static> {
+ var_data(("symbolIdValue", o(vec![("guid", main)])), "SYMBOL_ID", "SYMBOL_ID")
+}
+
/// The format's type of a component property (`None`: not written).
fn prop_type(d: &PropDef) -> Option<&'static str> {
match d {
@@ -2499,7 +2778,7 @@ fn style_fields(st: &TextStyle, f: &mut Fields) {
TextDecoration::Strikethrough => "STRIKETHROUGH",
};
f.extend([
- ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&super::style_name(&st.font_style))), ("postscript", s(""))])),
+ ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&face_style(&st.font_family, &st.font_style))), ("postscript", s(""))])),
("fontSize", fl(st.font_size)),
("lineHeight", line_height),
("letterSpacing", letter_spacing),
@@ -2548,7 +2827,8 @@ fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> {
// The format has no WebP export setting.
figcraft_doc::ExportFormat::Png | figcraft_doc::ExportFormat::Webp => "PNG",
figcraft_doc::ExportFormat::Jpg => "JPEG",
- figcraft_doc::ExportFormat::Svg => "SVG",
+ // Nor an Android Vector Drawable one: the nearest is the other vector format.
+ figcraft_doc::ExportFormat::Svg | figcraft_doc::ExportFormat::VectorDrawable => "SVG",
figcraft_doc::ExportFormat::Pdf => "PDF",
};
let (ty, value) = match x.size {
diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs
index 1f0fe172..4198b1cc 100644
--- a/crates/format/src/fig/export_tests.rs
+++ b/crates/format/src/fig/export_tests.rs
@@ -7,7 +7,9 @@ use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::geom::{Affine, Point};
-use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger};
+use figcraft_doc::proto::{
+ Action, Direction, Easing, Flow, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, ScrollPosition, Transition, Trigger,
+};
use figcraft_doc::*;
use serde_json::Value;
@@ -511,6 +513,7 @@ impl Gen {
close_on_outside: true,
background: Some(Color::new(0.0, 0.0, 0.0, 0.5)),
offset: None,
+ anchor: OverlayAnchor::Screen,
},
3 => Action::SwapOverlay { dest },
4 => Action::ChangeTo { dest },
@@ -635,6 +638,12 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
let d = AutoLayout::default();
(l.grid_columns, l.grid_rows) = (d.grid_columns, d.grid_rows);
}
+ // Equal (one-share) tracks say what no track list says.
+ for tracks in [&mut l.column_tracks, &mut l.row_tracks] {
+ if tracks.iter().all(|t| *t == GridTrack::default()) {
+ tracks.clear();
+ }
+ }
}
n.child_layout.grid_align_h = None;
n.child_layout.grid_align_v = None;
@@ -1178,3 +1187,332 @@ fn local_samples_round_trip() {
assert_same(&a, &b, &format!("{} (export warnings: {warnings:?})", p.display()));
}
}
+
+#[test]
+fn a_vector_drawable_export_setting_is_written_as_svg_with_a_warning() {
+ let mut doc = Document::new("vd");
+ let page = doc.first_page().unwrap();
+ let r = doc.create(page, NodeKind::Rectangle, "Icon", 0.0, 0.0, 24.0, 24.0).unwrap();
+ doc.get_mut(r).unwrap().exports = vec![ExportSetting { format: ExportFormat::VectorDrawable, ..Default::default() }];
+ let (bytes, warnings) = export(&doc, None).unwrap();
+ assert_eq!(warnings, ["Android Vector Drawable (XML) export settings were written as SVG"]);
+ let (back, _) = import(&bytes, "x").unwrap();
+ let icon = back.nodes().find(|n| n.name == "Icon").unwrap();
+ assert_eq!(icon.exports.iter().map(|x| x.format).collect::>(), [ExportFormat::Svg]);
+}
+
+#[test]
+fn images_are_named_by_the_sha1_of_their_bytes() {
+ // FIPS 180 test vectors.
+ assert_eq!(export::sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
+ assert_eq!(export::sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
+ assert_eq!(export::sha1_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), "84983e441c3bd26ebaae4aa1f95129e5e54670f1");
+ assert_eq!(export::sha1_hex(&vec![b'a'; 1_000_000]), "34aa973cd4c4daa4f61eeb2bdbad27316534016f");
+ let (bytes, _) = export(&generate(3), None).unwrap();
+ let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
+ let names: Vec = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
+ assert!(!names.is_empty());
+ for name in names {
+ let data = crate::read_member(&mut z, &name, 1 << 26).unwrap();
+ assert_eq!(name.strip_prefix("images/"), Some(export::sha1_hex(&data).as_str()));
+ }
+ // Paints name the image by its 20 bytes.
+ let archive = Archive::open(&bytes).unwrap();
+ let (schema, message) = decode_canvas(&archive.canvas).unwrap();
+ let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+ let mut hashes = 0;
+ for nc in msg.get("nodeChanges").unwrap().list() {
+ for p in nc.get("fillPaints").map(Kv::list).unwrap_or_default() {
+ if let Some(h) = p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes) {
+ assert_eq!(h.len(), 20);
+ hashes += 1;
+ }
+ }
+ }
+ assert!(hashes > 0);
+}
+
+#[test]
+fn an_axis_without_a_maximum_size_is_written_as_infinite() {
+ let mut doc = Document::new("File");
+ let page = doc.first_page().unwrap();
+ let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap();
+ let node = doc.node_mut(id).unwrap();
+ node.layout = Some(AutoLayout::default());
+ node.child_layout.max_w = Some(1200.0);
+ node.child_layout.min_h = Some(40.0);
+ let (bytes, _) = export(&doc, None).unwrap();
+ let archive = Archive::open(&bytes).unwrap();
+ let (schema, message) = decode_canvas(&archive.canvas).unwrap();
+ let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+ let nc = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Subject")).unwrap();
+ let axes = |k: &str| {
+ let v = nc.get(k).unwrap().get("value").unwrap();
+ (v.get("x").cloned().unwrap(), v.get("y").cloned().unwrap())
+ };
+ assert_eq!(axes("maxSize"), (Kv::Float(1200.0), Kv::Float(f32::INFINITY)));
+ assert_eq!(axes("minSize"), (Kv::Float(0.0), Kv::Float(40.0)));
+ let (back, _) = import(&bytes, "File").unwrap();
+ let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout;
+ assert_eq!([cl.min_w, cl.max_w, cl.min_h, cl.max_h], [None, Some(1200.0), Some(40.0), None]);
+}
+
+#[test]
+fn compound_weights_are_spelt_the_way_font_families_name_their_faces() {
+ assert_eq!(export::face_style("Libre Franklin", "Semi Bold"), "SemiBold");
+ assert_eq!(export::face_style("Fira Sans", "SemiBold"), "SemiBold");
+ assert_eq!(export::face_style("Roboto", "Extra Light Italic"), "ExtraLight Italic");
+ assert_eq!(export::face_style("Roboto", "Bold"), "Bold");
+ assert_eq!(export::face_style("Inter", "SemiBold"), "Semi Bold");
+ assert_eq!(export::face_style("Inter Display", "Extra Bold"), "Extra Bold");
+}
+
+/// A component set whose variants share a text and a boolean property, its layers bound to them,
+/// and an instance of the second variant with its own label.
+fn set_with_properties() -> (Document, NodeId) {
+ fn mirror(doc: &mut Document, src: NodeId, parent: NodeId) {
+ let mut n = doc.node(src).unwrap().clone();
+ let kids = std::mem::take(&mut n.children);
+ n.id = doc.alloc_id();
+ n.source = Some(src);
+ let id = doc.insert(n, parent, None).unwrap();
+ for k in kids {
+ mirror(doc, k, id);
+ }
+ }
+ let mut doc = Document::new("File");
+ let page = doc.first_page().unwrap();
+ let glyph =
+ doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Glyph", 400.0, 0.0, 16.0, 16.0).unwrap();
+ doc.create(glyph, NodeKind::Rectangle, "Shape", 0.0, 0.0, 16.0, 16.0).unwrap();
+ let set = doc.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 300.0, 120.0).unwrap();
+ let mut variants = Vec::new();
+ for (i, name) in ["Size=Small", "Size=Large"].into_iter().enumerate() {
+ let props = BTreeMap::from([
+ ("Label".to_string(), PropDef::Text { default: "Button".into() }),
+ ("Show icon".to_string(), PropDef::Boolean { default: true }),
+ ]);
+ let v = doc.create(set, NodeKind::Component { description: String::new(), props }, name, 0.0, 60.0 * i as f64, 120.0, 40.0).unwrap();
+ let label = doc.create(v, NodeKind::Text(TextData::new("Button")), "Label", 0.0, 0.0, 60.0, 20.0).unwrap();
+ doc.node_mut(label).unwrap().prop_refs.insert("characters".into(), "Label".into());
+ // The icon is an instance placed in the variant: like the editor's, its `source` is its
+ // main, which the binding doesn't belong to.
+ let icon = doc.create(v, NodeKind::Instance { component: glyph, props: BTreeMap::new() }, "Icon", 70.0, 0.0, 16.0, 16.0).unwrap();
+ let n = doc.node_mut(icon).unwrap();
+ n.source = Some(glyph);
+ n.prop_refs.insert("visible".into(), "Show icon".into());
+ variants.push(v);
+ }
+ let large = variants[1];
+ let main = doc.node(large).unwrap().clone();
+ let id = doc.alloc_id();
+ let mut n = main.clone();
+ let props = BTreeMap::from([("Label".to_string(), Value::String("Hi".into()))]);
+ (n.id, n.kind, n.name, n.parent, n.source) = (id, NodeKind::Instance { component: large, props }, "Button".into(), None, None);
+ n.children.clear();
+ n.transform = Affine::translate((0.0, 200.0));
+ doc.insert(n, page, None).unwrap();
+ for k in main.children {
+ mirror(&mut doc, k, id);
+ }
+ let kids = doc.node(id).unwrap().children.clone();
+ for k in kids {
+ let n = doc.node_mut(k).unwrap();
+ if let NodeKind::Text(td) = &mut n.kind {
+ td.characters = "Hi".into();
+ td.runs.clear();
+ n.overrides.insert("characters".into());
+ }
+ }
+ (doc, id)
+}
+
+#[test]
+fn set_properties_live_on_the_set_with_a_copy_per_variant() {
+ let (doc, _) = set_with_properties();
+ let (bytes, _) = export(&doc, None).unwrap();
+ let archive = Archive::open(&bytes).unwrap();
+ let (schema, message) = decode_canvas(&archive.canvas).unwrap();
+ let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+ let changes = msg.get("nodeChanges").unwrap().list();
+ let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap());
+ let named = |name: &str| changes.iter().filter(|n| n.get("name").and_then(Kv::str) == Some(name)).collect::>();
+ let id_of = |n: &Kv| key(n.get("guid").unwrap());
+ let parent_of = |n: &Kv| key(n.get("parentIndex").unwrap().get("guid").unwrap());
+
+ // The set: every definition, the variant property first, with its values in order.
+ let set = named("Button").into_iter().find(|n| n.get("isStateGroup").is_some()).unwrap();
+ let defs = set.get("componentPropDefs").unwrap().list();
+ let def = |name: &str| defs.iter().find(|d| d.get("name").and_then(Kv::str) == Some(name)).unwrap();
+ assert_eq!(def("Size").get("type").and_then(Kv::str), Some("VARIANT"));
+ assert_eq!(def("Label").get("type").and_then(Kv::str), Some("TEXT"));
+ assert_eq!(def("Show icon").get("type").and_then(Kv::str), Some("BOOL"));
+ assert_eq!(defs.first().and_then(|d| d.get("name")).and_then(Kv::str), Some("Size"));
+ for d in defs {
+ assert!(d.get("varValue").is_some() && d.get("sortPosition").is_some(), "{d:?}");
+ }
+ let label_default = def("Label").get("varValue").unwrap().get("value").unwrap().get("textDataValue").unwrap();
+ assert_eq!(label_default.get("characters").and_then(Kv::str), Some("Button"));
+ assert_eq!(label_default.get("lines").map(|l| l.list().len()), Some(1));
+ let orders = set.get("stateGroupPropertyValueOrders").unwrap().list();
+ assert_eq!(orders.len(), 1);
+ let values: Vec<&str> = orders[0].get("values").unwrap().list().iter().filter_map(Kv::str).collect();
+ assert_eq!((orders[0].get("property").and_then(Kv::str), values), (Some("Size"), vec!["Small", "Large"]));
+
+ // Each variant: its own copy of each definition and its variant value; its layers name the copy.
+ let set_ids: BTreeMap =
+ defs.iter().map(|d| (key(d.get("id").unwrap()), d.get("name").and_then(Kv::str).unwrap().to_string())).collect();
+ let mut copies = BTreeSet::new();
+ let mut large_label = None;
+ for (name, value) in [("Size=Small", "Small"), ("Size=Large", "Large")] {
+ let v = named(name)[0];
+ let mut own = BTreeMap::new();
+ for d in v.get("componentPropDefs").unwrap().list() {
+ let parent = set_ids.get(&key(d.get("parentPropDefId").unwrap())).unwrap().clone();
+ assert!(copies.insert(key(d.get("id").unwrap())), "copies are the variant's own");
+ own.insert(parent, key(d.get("id").unwrap()));
+ }
+ assert_eq!(own.keys().map(String::as_str).collect::>(), ["Label", "Show icon"]);
+ let specs = v.get("variantPropSpecs").unwrap().list();
+ assert_eq!(set_ids.get(&key(specs[0].get("propDefId").unwrap())).map(String::as_str), Some("Size"));
+ assert_eq!(specs[0].get("value").and_then(Kv::str), Some(value));
+ for (layer, prop, field) in [("Label", "Label", "TEXT_DATA"), ("Icon", "Show icon", "VISIBLE")] {
+ let l = named(layer).into_iter().find(|l| parent_of(l) == id_of(v)).unwrap();
+ let r = &l.get("componentPropRefs").unwrap().list()[0];
+ assert_eq!((key(r.get("defID").unwrap()), r.get("componentPropNodeField").and_then(Kv::str)), (own[prop].clone(), Some(field)));
+ let entry = &l.get("parameterConsumptionMap").unwrap().get("entries").unwrap().list()[0];
+ let def_id = entry.get("variableData").unwrap().get("value").unwrap().get("propRefValue").unwrap().get("defId").unwrap();
+ assert_eq!(key(def_id), own[prop]);
+ }
+ if value == "Large" {
+ large_label = Some(own["Label"].clone());
+ }
+ }
+
+ // The instance names the large variant's copy, with the value as variable data too.
+ let inst = named("Button").into_iter().find(|n| n.get("type").and_then(Kv::str) == Some("INSTANCE")).unwrap();
+ let a = &inst.get("componentPropAssignments").unwrap().list()[0];
+ assert_eq!(Some(key(a.get("defID").unwrap())), large_label);
+ let chars = a.get("varValue").unwrap().get("value").unwrap().get("textDataValue").unwrap().get("characters");
+ assert_eq!(chars.and_then(Kv::str), Some("Hi"));
+}
+
+#[test]
+fn set_properties_round_trip() {
+ let (doc, inst) = set_with_properties();
+ let (bytes, warnings) = export(&doc, None).unwrap();
+ assert!(warnings.is_empty(), "{warnings:?}");
+ let (back, _) = import(&bytes, "File").unwrap();
+ for name in ["Size=Small", "Size=Large"] {
+ let v = back.nodes().find(|n| n.name == name).unwrap();
+ let NodeKind::Component { props, .. } = &v.kind else { panic!("{name} is a component") };
+ assert_eq!(props.get("Label"), Some(&PropDef::Text { default: "Button".into() }), "{name}");
+ assert_eq!(props.get("Show icon"), Some(&PropDef::Boolean { default: true }), "{name}");
+ let label = v.children.iter().filter_map(|c| back.node(*c)).find(|c| c.name == "Label").unwrap();
+ assert_eq!(label.prop_refs.get("characters").map(String::as_str), Some("Label"));
+ }
+ let name = &doc.node(inst).unwrap().name;
+ let i = back.nodes().find(|n| &n.name == name && matches!(n.kind, NodeKind::Instance { .. })).unwrap();
+ let NodeKind::Instance { props, .. } = &i.kind else { panic!() };
+ assert_eq!(props.get("Label"), Some(&Value::String("Hi".into())));
+}
+
+#[test]
+fn an_instance_without_its_mains_maximum_size_overrides_it_with_infinity() {
+ let mut doc = Document::new("File");
+ let page = doc.first_page().unwrap();
+ let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Box", 0.0, 0.0, 100.0, 40.0).unwrap();
+ doc.node_mut(main).unwrap().child_layout.max_w = Some(300.0);
+ doc.create(page, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Box", 0.0, 100.0, 100.0, 40.0).unwrap();
+ let (bytes, _) = export(&doc, None).unwrap();
+ let archive = Archive::open(&bytes).unwrap();
+ let (schema, message) = decode_canvas(&archive.canvas).unwrap();
+ let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+ let inst = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("type").and_then(Kv::str) == Some("INSTANCE")).unwrap();
+ let overrides = inst.get("symbolData").unwrap().get("symbolOverrides").unwrap().list();
+ let max = overrides.iter().find_map(|o| o.get("maxSize")).unwrap().get("value").unwrap();
+ assert_eq!((max.get("x"), max.get("y")), (Some(&Kv::Float(f32::INFINITY)), Some(&Kv::Float(f32::INFINITY))));
+}
+
+#[test]
+fn grid_track_sizes_are_written_and_read_back() {
+ let mut doc = Document::new("File");
+ let page = doc.first_page().unwrap();
+ let id = doc.create(page, NodeKind::Frame, "Row", 0.0, 0.0, 720.0, 70.0).unwrap();
+ let columns = vec![GridTrack::fixed(320.0), GridTrack::hug(), GridTrack { sizing: Sizing::Fill, value: 2.0 }, GridTrack::fixed(34.0)];
+ doc.node_mut(id).unwrap().layout = Some(AutoLayout {
+ mode: LayoutMode::Grid,
+ grid_columns: 4,
+ grid_rows: 1,
+ column_tracks: columns.clone(),
+ row_tracks: vec![GridTrack::fixed(69.0)],
+ ..AutoLayout::default()
+ });
+ let (bytes, warnings) = export(&doc, None).unwrap();
+ assert!(warnings.is_empty(), "{warnings:?}");
+ let archive = Archive::open(&bytes).unwrap();
+ let (schema, message) = decode_canvas(&archive.canvas).unwrap();
+ let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+ let row = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Row")).unwrap();
+ let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap());
+ let order: Vec = row.get("gridColumns").unwrap().get("entries").unwrap().list().iter().map(|e| key(e.get("id").unwrap())).collect();
+ let sizes = row.get("gridColumnsSizing").unwrap().get("entries").unwrap().list();
+ let written: Vec<(String, f64)> = order
+ .iter()
+ .map(|id| {
+ let size = sizes.iter().find(|e| key(e.get("id").unwrap()) == *id).unwrap().get("trackSize").unwrap();
+ let (min, max) = (size.get("minSizing").unwrap(), size.get("maxSizing").unwrap());
+ assert_eq!(min, max);
+ (min.get("type").and_then(Kv::str).unwrap().to_string(), min.get("value").unwrap().f64().unwrap())
+ })
+ .collect();
+ let types: Vec<(&str, f64)> = written.iter().map(|(t, v)| (t.as_str(), *v)).collect();
+ assert_eq!(types, [("FIXED", 320.0), ("HUG", 0.0), ("FLEX", 2.0), ("FIXED", 34.0)]);
+ let (back, _) = import(&bytes, "File").unwrap();
+ let l = back.nodes().find(|n| n.name == "Row").unwrap().layout.clone().unwrap();
+ assert_eq!(l.column_tracks, columns);
+ assert_eq!(l.row_tracks, vec![GridTrack::fixed(69.0)]);
+}
+
+#[test]
+fn variants_that_disagree_on_a_default_are_a_warning() {
+ let (mut doc, _) = set_with_properties();
+ let large = doc.nodes().find(|n| n.name == "Size=Large").unwrap().id;
+ if let NodeKind::Component { props, .. } = &mut doc.node_mut(large).unwrap().kind {
+ props.insert("Label".into(), PropDef::Text { default: "Large".into() });
+ }
+ let (_, warnings) = export(&doc, None).unwrap();
+ assert!(warnings.iter().any(|w| w.contains("different defaults")), "{warnings:?}");
+}
+
+#[test]
+fn a_name_used_with_two_types_in_a_set_writes_both_definitions() {
+ let (mut doc, _) = set_with_properties();
+ let large = doc.nodes().find(|n| n.name == "Size=Large").unwrap().id;
+ if let NodeKind::Component { props, .. } = &mut doc.node_mut(large).unwrap().kind {
+ props.insert("Label".into(), PropDef::Boolean { default: true });
+ }
+ let (bytes, _) = export(&doc, None).unwrap();
+ let archive = Archive::open(&bytes).unwrap();
+ let (schema, message) = decode_canvas(&archive.canvas).unwrap();
+ let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+ let changes = msg.get("nodeChanges").unwrap().list();
+ let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap());
+ let set = changes.iter().find(|n| n.get("isStateGroup").is_some()).unwrap();
+ let written: BTreeSet = set.get("componentPropDefs").unwrap().list().iter().map(|d| key(d.get("id").unwrap())).collect();
+ let labels: Vec<&str> = set
+ .get("componentPropDefs")
+ .unwrap()
+ .list()
+ .iter()
+ .filter(|d| d.get("name").and_then(Kv::str) == Some("Label"))
+ .filter_map(|d| d.get("type").and_then(Kv::str))
+ .collect();
+ assert_eq!(labels.len(), 2, "{labels:?}");
+ // Every variant copy names a definition the set holds.
+ for v in changes.iter().filter(|n| n.get("variantPropSpecs").is_some()) {
+ for d in v.get("componentPropDefs").unwrap().list() {
+ assert!(written.contains(&key(d.get("parentPropDefId").unwrap())));
+ }
+ }
+}
diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs
index ab8f3218..0f431082 100644
--- a/crates/format/src/fig/kiwi.rs
+++ b/crates/format/src/fig/kiwi.rs
@@ -563,7 +563,11 @@ pub mod encode {
Type::Byte => w.byte(ranged(0, 255)? as u8),
Type::Int => w.var_int(ranged(i32::MIN.into(), i32::MAX.into())? as i32),
Type::Uint => w.var_uint(ranged(0, u32::MAX.into())? as u32),
- Type::Float => w.var_float(v.f64().map(|x| x as f32).filter(|x| x.is_finite()).ok_or_else(bad)?),
+ // Infinity is a value of its own (an axis with no maximum size); NaN is not.
+ Type::Float => w.var_float(match v {
+ Kv::Float(f) if f.is_infinite() => *f,
+ _ => v.f64().map(|x| x as f32).filter(|x| x.is_finite()).ok_or_else(bad)?,
+ }),
Type::Str => w.string(v.str().ok_or_else(bad)?),
Type::Int64 => w.var_int64(ranged(i64::MIN.into(), i64::MAX.into())? as i64),
Type::Uint64 => w.var_uint64(ranged(0, u64::MAX.into())? as u64),
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 4136a42f..30f6e548 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -254,14 +254,193 @@ fn image_index(images: &&BTreeMap, s: S) -> Re
s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height }))
}
+/// A serializer adapter that writes `0` in place of every NaN or infinite float. JSON has no such
+/// numbers (`serde_json` writes `null`, which the reader rejects), so one bad value must not stop
+/// Save, autosave or recovery copies of the whole document. Everything else passes through.
+struct Finite(S);
+
+/// Serializes a value through a [`Finite`] adapter around the serializer it is written with.
+struct FiniteValue<'a, T: ?Sized>(&'a T);
+
+impl Serialize for FiniteValue<'_, T> {
+ fn serialize(&self, s: S) -> Result {
+ self.0.serialize(Finite(s))
+ }
+}
+
+macro_rules! finite_pass {
+ ($($name:ident($ty:ty)),* $(,)?) => {
+ $(fn $name(self, v: $ty) -> Result { self.0.$name(v) })*
+ };
+}
+
+impl Serializer for Finite {
+ type Ok = S::Ok;
+ type Error = S::Error;
+ type SerializeSeq = Finite;
+ type SerializeTuple = Finite;
+ type SerializeTupleStruct = Finite;
+ type SerializeTupleVariant = Finite;
+ type SerializeMap = Finite;
+ type SerializeStruct = Finite;
+ type SerializeStructVariant = Finite;
+
+ finite_pass!(
+ serialize_bool(bool),
+ serialize_i8(i8),
+ serialize_i16(i16),
+ serialize_i32(i32),
+ serialize_i64(i64),
+ serialize_u8(u8),
+ serialize_u16(u16),
+ serialize_u32(u32),
+ serialize_u64(u64),
+ serialize_i128(i128),
+ serialize_u128(u128),
+ serialize_char(char),
+ serialize_str(&str),
+ serialize_bytes(&[u8])
+ );
+
+ fn serialize_f32(self, v: f32) -> Result {
+ self.0.serialize_f32(if v.is_finite() { v } else { 0.0 })
+ }
+ fn serialize_f64(self, v: f64) -> Result {
+ self.0.serialize_f64(if v.is_finite() { v } else { 0.0 })
+ }
+ fn serialize_none(self) -> Result {
+ self.0.serialize_none()
+ }
+ fn serialize_some(self, v: &T) -> Result {
+ self.0.serialize_some(&FiniteValue(v))
+ }
+ fn serialize_unit(self) -> Result {
+ self.0.serialize_unit()
+ }
+ fn serialize_unit_struct(self, name: &'static str) -> Result {
+ self.0.serialize_unit_struct(name)
+ }
+ fn serialize_unit_variant(self, name: &'static str, idx: u32, variant: &'static str) -> Result {
+ self.0.serialize_unit_variant(name, idx, variant)
+ }
+ fn serialize_newtype_struct(self, name: &'static str, v: &T) -> Result {
+ self.0.serialize_newtype_struct(name, &FiniteValue(v))
+ }
+ fn serialize_newtype_variant(self, name: &'static str, idx: u32, variant: &'static str, v: &T) -> Result {
+ self.0.serialize_newtype_variant(name, idx, variant, &FiniteValue(v))
+ }
+ fn serialize_seq(self, len: Option) -> Result {
+ self.0.serialize_seq(len).map(Finite)
+ }
+ fn serialize_tuple(self, len: usize) -> Result {
+ self.0.serialize_tuple(len).map(Finite)
+ }
+ fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result {
+ self.0.serialize_tuple_struct(name, len).map(Finite)
+ }
+ fn serialize_tuple_variant(
+ self,
+ name: &'static str,
+ idx: u32,
+ variant: &'static str,
+ len: usize,
+ ) -> Result {
+ self.0.serialize_tuple_variant(name, idx, variant, len).map(Finite)
+ }
+ fn serialize_map(self, len: Option) -> Result {
+ self.0.serialize_map(len).map(Finite)
+ }
+ fn serialize_struct(self, name: &'static str, len: usize) -> Result {
+ self.0.serialize_struct(name, len).map(Finite)
+ }
+ fn serialize_struct_variant(
+ self,
+ name: &'static str,
+ idx: u32,
+ variant: &'static str,
+ len: usize,
+ ) -> Result {
+ self.0.serialize_struct_variant(name, idx, variant, len).map(Finite)
+ }
+ fn is_human_readable(&self) -> bool {
+ self.0.is_human_readable()
+ }
+}
+
+macro_rules! finite_elements {
+ ($($trait:ident::$method:ident),* $(,)?) => {
+ $(impl serde::ser::$trait for Finite {
+ type Ok = S::Ok;
+ type Error = S::Error;
+ fn $method(&mut self, v: &T) -> Result<(), S::Error> {
+ self.0.$method(&FiniteValue(v))
+ }
+ fn end(self) -> Result {
+ self.0.end()
+ }
+ })*
+ };
+}
+
+finite_elements!(
+ SerializeSeq::serialize_element,
+ SerializeTuple::serialize_element,
+ SerializeTupleStruct::serialize_field,
+ SerializeTupleVariant::serialize_field
+);
+
+impl serde::ser::SerializeMap for Finite {
+ type Ok = S::Ok;
+ type Error = S::Error;
+ fn serialize_key(&mut self, k: &T) -> Result<(), S::Error> {
+ self.0.serialize_key(&FiniteValue(k))
+ }
+ fn serialize_value(&mut self, v: &T) -> Result<(), S::Error> {
+ self.0.serialize_value(&FiniteValue(v))
+ }
+ fn end(self) -> Result {
+ self.0.end()
+ }
+}
+
+impl serde::ser::SerializeStruct for Finite {
+ type Ok = S::Ok;
+ type Error = S::Error;
+ fn serialize_field(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> {
+ self.0.serialize_field(key, &FiniteValue(v))
+ }
+ fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> {
+ self.0.skip_field(key)
+ }
+ fn end(self) -> Result {
+ self.0.end()
+ }
+}
+
+impl serde::ser::SerializeStructVariant for Finite {
+ type Ok = S::Ok;
+ type Error = S::Error;
+ fn serialize_field(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> {
+ self.0.serialize_field(key, &FiniteValue(v))
+ }
+ fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> {
+ self.0.skip_field(key)
+ }
+ fn end(self) -> Result {
+ self.0.end()
+ }
+}
+
/// Serialize the document (without images) to the JSON value stored in `document.json`.
+/// NaN and infinite numbers are written as `0`.
pub fn to_json(doc: &Document) -> Result {
- serde_json::to_value(DocumentJson::new(doc)).map_err(io_err)
+ DocumentJson::new(doc).serialize(Finite(serde_json::value::Serializer)).map_err(io_err)
}
/// Write the document (without images) as `document.json` content to `w`.
+/// NaN and infinite numbers are written as `0`.
pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> {
- serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err)
+ DocumentJson::new(doc).serialize(Finite(&mut serde_json::Serializer::new(w))).map_err(io_err)
}
/// Parse `document.json` content.
@@ -840,6 +1019,37 @@ mod tests {
assert!(load_bytes(&json).is_ok());
}
+ #[test]
+ fn non_finite_numbers_are_saved_as_zero() {
+ let mut d = Document::new("NaN");
+ let p = d.first_page().unwrap();
+ let r = d.create(p, NodeKind::Rectangle, "R", 1.0, 2.0, 3.0, 4.0).unwrap();
+ assert!(save_bytes(&d, None).is_ok());
+ d.get_mut(r).unwrap().width = f64::NAN;
+ d.get_mut(r).unwrap().opacity = f32::INFINITY;
+ // Saving never fails; non-finite numbers are written as 0 and the file reopens.
+ let bytes = save_bytes(&d, None).unwrap();
+ let (back, _) = load_bytes(&bytes).unwrap();
+ assert_eq!(back.node_count(), d.node_count());
+ let n = back.get(r).unwrap();
+ assert_eq!(n.width, 0.0);
+ assert_eq!(n.opacity, 0.0);
+ assert_eq!(n.height, 4.0);
+ let mut out = Vec::new();
+ write_json(&d, &mut out).unwrap();
+ let (back, _) = from_json(std::str::from_utf8(&out).unwrap()).unwrap();
+ let n = back.get(r).unwrap();
+ assert_eq!(n.width, 0.0);
+ assert_eq!(n.opacity, 0.0);
+ assert_eq!(n.height, 4.0);
+ let v = to_json(&d).unwrap();
+ let (back, _) = from_json(&v.to_string()).unwrap();
+ let n = back.get(r).unwrap();
+ assert_eq!(n.width, 0.0);
+ assert_eq!(n.opacity, 0.0);
+ assert_eq!(n.height, 4.0);
+ }
+
#[test]
fn garbage_is_an_error() {
assert!(load_bytes(b"not a zip").is_err());
diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs
index 56e50e5e..61869916 100644
--- a/crates/format/src/rest/proto.rs
+++ b/crates/format/src/rest/proto.rs
@@ -4,7 +4,7 @@
use figcraft_doc::proto::{
Action, ConditionalBlock, DeviceRotation, Direction, Easing, Expr, ExprOp, Interaction, KeyMods, MAX_ACTION_DEPTH, MAX_EXPR_DEPTH, Overflow,
- OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, gamepad_button,
+ OverlayAnchor, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, gamepad_button,
};
use figcraft_doc::{Node, NodeId, VariableType, VariableValue};
use serde_json::{Value, json};
@@ -322,7 +322,7 @@ impl Ctx<'_> {
.and_then(|v| Some([num(v, "x")?, num(v, "y")?]))
.filter(|o| o.iter().all(|c| c.is_finite()))
.map(|o| o.map(|c| c.clamp(-MAX_OFFSET, MAX_OFFSET)));
- Action::OpenOverlay { dest, position, close_on_outside, background, offset }
+ Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor: OverlayAnchor::Screen }
}
Some(_) => {
self.warn("prototype navigations of unknown types were skipped");
diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs
index f9acf2ea..5ea5264f 100644
--- a/crates/format/src/rest/tests.rs
+++ b/crates/format/src/rest/tests.rs
@@ -365,6 +365,17 @@ fn text_styles_and_runs() {
assert!(!named(&doc, "NoFills").fills.is_empty(), "text keeps its default fill");
}
+#[test]
+fn max_lines_apply_only_while_truncating() {
+ let (doc, _) = one(json!([
+ {"id": "1", "name": "Auto", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}, "characters": "a",
+ "style": {"textAutoResize": "HEIGHT", "textTruncation": "DISABLED", "maxLines": 2}}
+ ]));
+ let NodeKind::Text(td) = &named(&doc, "Auto").kind else { panic!() };
+ assert_eq!((td.auto_resize, td.max_lines), (TextAutoResize::Height, 0));
+ assert!(!td.truncates());
+}
+
#[test]
fn auto_layout_constraints_and_grids() {
let (doc, _) = one(json!([
diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs
index 99ceb500..18e5015e 100644
--- a/crates/format/src/rest/text.rs
+++ b/crates/format/src/rest/text.rs
@@ -69,10 +69,13 @@ fn paragraph(s: &Value, td: &mut TextData) {
// Missing means a fixed box.
_ => TextAutoResize::None,
};
- if text(s, "textTruncation") == Some("ENDING") {
+ // A line limit applies only while truncation is on; a leftover `maxLines` is ignored.
+ let ending = text(s, "textTruncation") == Some("ENDING");
+ td.max_lines = num(s, "maxLines").filter(|n| ending && *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
+ // Auto height with a line limit keeps hugging its lines up to the limit.
+ if ending && !td.truncates() {
td.auto_resize = TextAutoResize::Truncate;
}
- td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0);
td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0);
td.list_spacing = len(s, "listSpacing").unwrap_or(0.0).min(10_000.0);
diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs
index b4cf0794..1b23191e 100644
--- a/crates/mcp/src/tools.rs
+++ b/crates/mcp/src/tools.rs
@@ -213,11 +213,11 @@ pub fn tool_definitions() -> Vec {
tool(
"export",
"Export",
- "Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.",
+ "Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
obj(
json!({
"ids": ids_schema("Layers to export (several are composited into one image)"),
- "format": {"type": "string", "enum": ["png", "jpg", "svg", "webp", "pdf", "fig"], "description": "Format (default png, or from the path's extension)"},
+ "format": {"type": "string", "enum": ["png", "jpg", "svg", "webp", "pdf", "xml", "fig"], "description": "Format (default png, or from the path's extension)"},
"scale": num("Pixel scale for raster formats (default 1, max 64)"),
"size": string("Instead of `scale`: `2x`, or a width or height in pixels like `512w` / `512h`"),
"path": string("Output file path"),
@@ -230,6 +230,7 @@ pub fn tool_definitions() -> Vec {
"svgOutlineText": boolean("SVG: text as outlines (default true); false keeps `` elements"),
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
+ "vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
}),
&[],
),
@@ -879,8 +880,8 @@ fn export(b: &mut dyn Backend, a: &Map) -> Result) -> Result) -> Result Ok(ToolResult::image(&bytes, "image/jpeg", &json!({"format": "jpg", "bytes": bytes.len()}))),
"webp" => Ok(ToolResult::image(&bytes, "image/webp", &json!({"format": "webp", "bytes": bytes.len()}))),
"svg" => Ok(ToolResult::text(String::from_utf8_lossy(&bytes).into_owned())),
+ "xml" => {
+ let info = json!({"format": "xml", "bytes": bytes.len(), "warnings": r.get("warnings").cloned().unwrap_or_else(|| json!([]))});
+ let mut out = ToolResult::text(String::from_utf8_lossy(&bytes).into_owned());
+ out.content.extend(ToolResult::json(&info).content);
+ Ok(out)
+ }
other => {
let mut out = json!({"format": other, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)});
if let Some(w) = r.get("warnings") {
diff --git a/crates/mcp/tests/it/export_xml.rs b/crates/mcp/tests/it/export_xml.rs
new file mode 100644
index 00000000..9ae42549
--- /dev/null
+++ b/crates/mcp/tests/it/export_xml.rs
@@ -0,0 +1,27 @@
+//! The `export` tool's Android Vector Drawable format: the XML as text, then its warnings.
+
+use crate::common::Client;
+use serde_json::{Value, json};
+
+#[test]
+fn xml_comes_back_as_text_and_its_warnings() {
+ let mut c = Client::new();
+ let id = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 24, "height": 16}));
+ let r = c.call("export", json!({"ids": [id], "format": "xml"}));
+ assert_eq!(r["isError"], false, "{r}");
+ let blocks = r["content"].as_array().unwrap();
+ let xml = blocks[0]["text"].as_str().unwrap();
+ assert!(xml.starts_with(" {
pub skip: &'a [NodeId],
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
pub lod: bool,
+ pub magnified_image_pixels: bool,
/// The surfaces under the one being drawn into, when it is an isolated layer (for backdrops).
pub under: Option<&'a Under<'a>>,
/// Seconds into animated GIFs (0: their first frame; only the presenter plays them).
@@ -116,10 +117,21 @@ pub fn ts_path(p: &BezPath) -> Option {
b.finish()
}
+const IMAGE_PIXELS_SHOWN_FROM_MAGNIFICATION: f64 = 2.0;
+
+fn pixels_when_magnified(prep: Prepared, device_scale: f64) -> Prepared {
+ let Prepared::Pattern { pixmap, mode, quality, opacity, transform } = prep else { return prep };
+ let image_scale = f64::from(transform.sx * transform.sy - transform.kx * transform.ky).abs().sqrt();
+ let magnification = device_scale * image_scale;
+ let quality = if magnification >= IMAGE_PIXELS_SHOWN_FROM_MAGNIFICATION { FilterQuality::Nearest } else { quality };
+ Prepared::Pattern { pixmap, mode, quality, opacity, transform }
+}
+
fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) {
let scale = xf.determinant().abs().sqrt();
for p in paints {
let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue };
+ let prep = if ctx.magnified_image_pixels && matches!(p.kind, PaintKind::Image(_)) { pixels_when_magnified(prep, scale) } else { prep };
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
if p.blend == BlendMode::PlusDarker {
// No tiny-skia mode: the paint alone over its pixels, then blended in.
diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs
index 70e0d582..3555ff44 100644
--- a/crates/render/src/glass.rs
+++ b/crates/render/src/glass.rs
@@ -6,9 +6,20 @@
//! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`.
//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
//! distance transform of the shape's coverage.
+//!
+//! The shape's coverage, distances and normals depend only on the shape and the zoom. Once a
+//! later render asks for the same shape at the same zoom they are worked out for the whole shape,
+//! in its own pixel frame, and kept in a cache of at most [`MAX_CACHED_BYTES`]: moving or editing
+//! what lies under the glass, dragging its sliders, panning and patching the canvas reuse them.
+//! Zooming doesn't (each zoom is new).
+
+use std::collections::HashMap;
+use std::hash::{DefaultHasher, Hash, Hasher};
+use std::sync::{Arc, Mutex, OnceLock};
use figcraft_doc::Glass;
use figcraft_geom::Affine;
+use figcraft_geom::kurbo::Shape as _;
use tiny_skia::Pixmap;
use crate::fx;
@@ -188,6 +199,211 @@ fn light_reach(band: f64, intensity: f64) -> f64 {
/// zoom would otherwise need a huge margin of coverage around every cut).
const MAX_LIGHT_PX: f64 = 512.0;
+/// A shape's coverage (alpha), distance to its edge and smoothed inward normals over a window of
+/// pixels whose top-left pixel is (`x0`, `y0`).
+struct Fields {
+ x0: i64,
+ y0: i64,
+ w: usize,
+ h: usize,
+ cov: Vec,
+ dist: Vec,
+ normals: Vec<(f32, f32)>,
+}
+
+/// Bytes a field pixel holds: coverage, distance and a normal.
+const FIELD_PX_BYTES: u64 = 1 + 4 + 8;
+
+impl Fields {
+ /// The fields of `cov`, the shape's coverage on the window at (`x0`, `y0`), normals smoothed
+ /// over `r` pixels.
+ fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize) -> Fields {
+ FIELDS_COMPUTED.with(|n| n.set(n.get() + 1));
+ let dist = inside_distance(cov);
+ let (w, h) = (cov.width() as usize, cov.height() as usize);
+ let normals = smooth_normals(&dist, w, h, r);
+ let cov = cov.data().chunks_exact(4).map(|p| p.get(3).copied().unwrap_or(0)).collect();
+ Fields { x0, y0, w, h, cov, dist, normals }
+ }
+
+ /// Coverage (0–1), distance and inward normal at pixel (`x`, `y`); `None` outside the window.
+ fn at(&self, x: i64, y: i64) -> Option<(f32, f32, (f32, f32))> {
+ let (fx, fy) = (x.checked_sub(self.x0)?, y.checked_sub(self.y0)?);
+ if fx < 0 || fy < 0 || fx >= self.w as i64 || fy >= self.h as i64 {
+ return None;
+ }
+ let i = fy as usize * self.w + fx as usize;
+ Some((f32::from(*self.cov.get(i)?) / 255.0, *self.dist.get(i)?, *self.normals.get(i)?))
+ }
+}
+
+thread_local! {
+ static FIELDS_COMPUTED: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// How many times this thread has worked out a glass shape's fields: what a cache miss costs. For
+/// tests and benchmarks.
+pub fn glass_fields_computed() -> u64 {
+ FIELDS_COMPUTED.with(std::cell::Cell::get)
+}
+
+/// What a glass shape's fields depend on, with the transform's translation reduced to its fraction
+/// of a pixel: the same shape at the same zoom has the same key wherever it is and however a render
+/// cuts it.
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+struct ShapeKey {
+ path: u64,
+ linear: [u64; 4],
+ frac: (i64, i64),
+ margin: u32,
+ r: usize,
+}
+
+/// The cached fields of a shape whose window holds at most this many pixels; bigger shapes (a
+/// pane far larger than the view) are worked out for the part a render shows each time.
+#[cfg(not(target_arch = "wasm32"))]
+const MAX_SHAPE_PX: u64 = 4 << 20;
+#[cfg(target_arch = "wasm32")]
+const MAX_SHAPE_PX: u64 = 1 << 20;
+/// Bytes the glass shape cache holds at most (less on the web, where memory never shrinks).
+#[cfg(not(target_arch = "wasm32"))]
+const MAX_CACHED_BYTES: u64 = 128 << 20;
+#[cfg(target_arch = "wasm32")]
+const MAX_CACHED_BYTES: u64 = 32 << 20;
+
+/// A shape's fields once worked out; parallel bands wanting the same shape wait for one of them.
+type Slot = Arc>>>;
+
+/// The shapes' fields by key, at most `budget` bytes; the least recently used go first. A shape is
+/// kept from the second render that asks for it: one asked for once (each step of a zoom, a pane
+/// dragged by fractions of a pixel) is cheaper worked out for just the part a render shows.
+struct ShapeCache {
+ map: HashMap,
+ /// Shapes asked for but not kept, by the render that first asked.
+ seen: HashMap,
+ held: u64,
+ tick: u64,
+ budget: u64,
+}
+
+/// Shapes remembered as asked for once; past this many they are forgotten.
+const MAX_SEEN: usize = 256;
+
+impl ShapeCache {
+ fn new(budget: u64) -> Self {
+ Self { map: HashMap::new(), seen: HashMap::new(), held: 0, tick: 0, budget }
+ }
+
+ /// The slot for `key` (filled or about to be) asked for in render number `render`, making room
+ /// for `bytes`; `None` when it isn't kept (first asked for in this render, or too big).
+ fn slot(&mut self, key: &ShapeKey, bytes: u64, render: u64) -> Option {
+ self.tick += 1;
+ if let Some((slot, _, used)) = self.map.get_mut(key) {
+ *used = self.tick;
+ return Some(slot.clone());
+ }
+ if bytes > self.budget {
+ return None;
+ }
+ match self.seen.get(key) {
+ Some(first) if *first < render => {
+ self.seen.remove(key);
+ }
+ Some(_) => return None,
+ None => {
+ if self.seen.len() >= MAX_SEEN {
+ self.seen.clear();
+ }
+ self.seen.insert(key.clone(), render);
+ return None;
+ }
+ }
+ while self.held + bytes > self.budget {
+ let oldest = self.map.iter().min_by_key(|(_, (_, _, used))| *used).map(|(k, _)| k.clone())?;
+ if let Some((_, b, _)) = self.map.remove(&oldest) {
+ self.held -= b;
+ }
+ }
+ let slot = Slot::default();
+ self.map.insert(key.clone(), (slot.clone(), bytes, self.tick));
+ self.held += bytes;
+ Some(slot)
+ }
+}
+
+fn shape_cache() -> &'static Mutex {
+ static C: OnceLock> = OnceLock::new();
+ C.get_or_init(|| Mutex::new(ShapeCache::new(MAX_CACHED_BYTES)))
+}
+
+/// The fields `glass` reads for a `w` × `h` layer whose `shape` maps through `xf` (local → layer
+/// pixels), exact for distances up to the margin `m`, normals smoothed over `r` pixels, and the
+/// shift from layer pixels to the fields' pixels.
+///
+/// A shape asked for again in a later render, whose window fits [`MAX_SHAPE_PX`], is worked out
+/// whole, in its own frame (the translation's fraction of a pixel), and kept: a band, a canvas
+/// patch or the next frame of a drag reads the same fields. Otherwise it is worked out for the part
+/// shown plus the margin.
+fn shape_fields(shape: &Outline, xf: Affine, w: u32, h: u32, m: u32, r: usize) -> Option<(Arc, (i64, i64))> {
+ if let Some(whole) = whole_shape(shape, xf, m, r) {
+ return Some(whole);
+ }
+ let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
+ Some((Arc::new(Fields::new(&cov, -i64::from(m), -i64::from(m), r)), (0, 0)))
+}
+
+/// [`shape_fields`] for a whole shape, from the cache; `None` when it isn't kept (first asked for
+/// in this render, too big, or its transform too far out).
+fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc, (i64, i64))> {
+ let [a, b, c, d, e, f] = xf.as_coeffs();
+ // The translation in steps of 1/65536 px (far finer than the rasteriser's 1/64, coarse enough
+ // that rounding errors in where a render starts land on the same step), its whole pixels (the
+ // shift) and the fraction left; rounding first carries 99.9999999 over to 100.
+ const STEPS: f64 = 65536.0;
+ let q = |v: f64| {
+ let q = (v * STEPS).round();
+ (q.is_finite() && q.abs() < 1e15).then_some(q as i64)
+ };
+ let (qe, qf) = (q(e)?, q(f)?);
+ let shift = (qe.div_euclid(STEPS as i64), qf.div_euclid(STEPS as i64));
+ let frac = (qe.rem_euclid(STEPS as i64), qf.rem_euclid(STEPS as i64));
+ let own = Affine::new([a, b, c, d, frac.0 as f64 / STEPS, frac.1 as f64 / STEPS]);
+ let bb = (own * shape.0.clone()).bounding_box();
+ let (x0, y0, x1, y1) = (bb.x0.floor(), bb.y0.floor(), bb.x1.ceil(), bb.y1.ceil());
+ if ![x0, y0, x1, y1].iter().all(|v| v.is_finite() && v.abs() < 1e9) {
+ return None;
+ }
+ // The margin in steps of 32 px, so dragging a slider that widens it a little keeps the same
+ // fields (a wider margin only adds exact distances further out: the same pixels).
+ let margin = m.div_ceil(32).checked_mul(32)?;
+ let (rw, rh) = ((x1 - x0) as u64 + 2, (y1 - y0) as u64 + 2);
+ let (ww, wh) = (rw + 2 * u64::from(margin), rh + 2 * u64::from(margin));
+ if ww.checked_mul(wh)? > MAX_SHAPE_PX {
+ return None;
+ }
+ let mut hasher = DefaultHasher::new();
+ crate::draw::hash_path(&shape.0, &mut hasher);
+ matches!(shape.1, tiny_skia::FillRule::EvenOdd).hash(&mut hasher);
+ let key = ShapeKey { path: hasher.finish(), linear: [a, b, c, d].map(f64::to_bits), frac, margin, r };
+ let compute = || {
+ // Rastered against the shape's own bounds (not the window), so the margin can't move a
+ // coverage value.
+ let raster = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((1.0 - x0, 1.0 - y0)) * own, rw as u32, rh as u32)?;
+ let mut win = Pixmap::new(ww as u32, wh as u32)?;
+ let (row, at) = (raster.width() as usize * 4, (margin as usize, margin as usize));
+ let stride = win.width() as usize * 4;
+ for (y, src) in raster.data().chunks_exact(row).enumerate() {
+ let start = (y + at.1) * stride + at.0 * 4;
+ win.data_mut().get_mut(start..start + row)?.copy_from_slice(src);
+ }
+ let origin = (x0 as i64 - 1 - i64::from(margin), y0 as i64 - 1 - i64::from(margin));
+ Some(Fields::new(&win, origin.0, origin.1, r))
+ };
+ let slot =
+ shape_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).slot(&key, ww * wh * FIELD_PX_BYTES, crate::render_number())?;
+ Some((slot.get_or_init(|| compute().map(Arc::new)).clone()?, shift))
+}
+
/// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from
/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit.
#[allow(clippy::too_many_arguments)]
@@ -201,10 +417,7 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
// smoothed from (2r + 1 further).
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
- let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
- let dist = inside_distance(&cov);
- let (cw, ch) = (cov.width() as usize, cov.height() as usize);
- let normals = smooth_normals(&dist, cw, ch, r);
+ let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?;
let mut frosted = back.clone();
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
@@ -221,17 +434,12 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
let depth = depth as f32;
let mut out = Pixmap::new(w, h)?;
let dst = out.data_mut();
- let cd = cov.data();
- let d_at = |x: usize, y: usize| dist.get(y * cw + x).copied().unwrap_or(0.0);
for y in 0..h as usize {
for x in 0..w as usize {
- let (mx, my) = (x + m as usize, y + m as usize);
- let c = f32::from(cd.get((my * cw + mx) * 4 + 3).copied().unwrap_or(0)) / 255.0;
+ let Some((c, d, inward)) = fields.at(x as i64 - shift.0, y as i64 - shift.1) else { continue };
if c <= 0.0 {
continue;
}
- let d = d_at(mx, my);
- let inward = normals.get(my * cw + mx).copied().unwrap_or((0.0, 0.0));
let t = (d / depth).min(1.0);
let bend = bend_max * (1.0 - t) * (1.0 - t);
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
@@ -284,6 +492,28 @@ mod tests {
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
}
+ #[test]
+ fn the_shape_cache_stays_within_its_bytes() {
+ let key = |i: u64| ShapeKey { path: i, linear: [0; 4], frac: (0, 0), margin: 0, r: 1 };
+ let mut c = ShapeCache::new(100);
+ // Kept from the second render that asks, not the first (nor again within the first).
+ assert!(c.slot(&key(1), 40, 1).is_none());
+ assert!(c.slot(&key(1), 40, 1).is_none());
+ assert!(c.slot(&key(1), 40, 2).is_some());
+ for k in [2, 3] {
+ assert!(c.slot(&key(k), 40, 2).is_none());
+ }
+ assert!(c.slot(&key(2), 40, 3).is_some());
+ // Using 1 again makes 2 the least recently used: 2 goes to make room for 3.
+ assert!(c.slot(&key(1), 40, 3).is_some());
+ assert!(c.slot(&key(3), 40, 3).is_some());
+ assert!(c.held <= 100, "{}", c.held);
+ assert!(c.map.contains_key(&key(1)) && c.map.contains_key(&key(3)) && !c.map.contains_key(&key(2)));
+ // More than it holds in all: never kept.
+ assert!(c.slot(&key(4), 101, 3).is_none() && c.slot(&key(4), 101, 4).is_none());
+ assert!(c.held <= 100 && !c.map.contains_key(&key(4)));
+ }
+
#[test]
fn light_reach_is_where_the_light_falls_under_half_a_level() {
let r = light_reach(10.0, 1.0);
diff --git a/crates/render/src/glass_bench_tests.rs b/crates/render/src/glass_bench_tests.rs
index 64c95835..1a4b4cbe 100644
--- a/crates/render/src/glass_bench_tests.rs
+++ b/crates/render/src/glass_bench_tests.rs
@@ -80,6 +80,18 @@ fn set_glass(d: &mut Document, pane: NodeId, g: Glass) {
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
}
+/// Writes the bench scene's render (rendered twice, so a kept shape is read) to the PNG named by
+/// `FIGCRAFT_GLASS_PNG`, to compare builds by eye.
+#[test]
+#[ignore = "writes a PNG: FIGCRAFT_GLASS_PNG=