Merge upstream/main into feat/wrap-style-paragraph-and-style-1827

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-11 17:55:44 -05:00
co-authored by Claude Opus 5.5
34 changed files with 621 additions and 89 deletions
+4
View File
@@ -29,6 +29,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+5
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "40 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -30,6 +34,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: figcraft
app-title: FigCraft
# Combined Latin presentation and core UI catalog. Required web builds enforce
Generated
+25 -23
View File
@@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1144,6 +1144,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -1546,6 +1547,7 @@ dependencies = [
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
"winit",
]
@@ -1783,7 +1785,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"arboard",
"craft-menu",
@@ -1815,7 +1817,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"clap",
"clap_complete",
@@ -1833,7 +1835,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1851,7 +1853,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1874,7 +1876,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"kurbo",
"serde",
@@ -1883,7 +1885,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1896,7 +1898,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"base64 0.22.1",
"craft-docpath",
@@ -1930,7 +1932,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"figcraft-doc",
"figcraft-layout",
@@ -1947,7 +1949,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"kurbo",
"serde",
@@ -1955,7 +1957,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1963,7 +1965,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1979,7 +1981,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1988,7 +1990,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -2005,7 +2007,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"figcraft-color",
"serde",
@@ -2018,7 +2020,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -2037,7 +2039,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"chrono",
"crc32fast",
@@ -2062,7 +2064,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -2078,7 +2080,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"core-text",
"craft-font-catalog",
@@ -2096,7 +2098,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -2124,7 +2126,7 @@ dependencies = [
[[package]]
name = "figcraft-vectordrawable"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -2138,7 +2140,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"base64 0.22.1",
"craft-web-api",
@@ -6761,7 +6763,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.0"
version = "0.18.0"
dependencies = [
"figcraft-engine",
"serde_json",
+2 -2
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.15.0"
version = "0.18.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -43,7 +43,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
+3 -1
View File
@@ -23,7 +23,9 @@ craft-window = { workspace = true }
# The suite's settings folder: `FIGCRAFT_CONFIG_DIR`, a portable marker, then the platform folder.
craft-settings = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
# `links`: without it eframe drops every `ctx.open_url` and hyperlink click (the update prompt's
# What's new and Download, About, Credits) with only a log warning.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
+10
View File
@@ -142,3 +142,13 @@ fn the_next_dialog_starts_in_the_folder_of_the_last_pick() {
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own");
}
/// The update prompt's What's new and Download, and the About and Credits links, open through
/// `ctx.open_url`. eframe drops those with only a log warning unless it is built with `links`.
#[test]
fn links_in_the_interface_open_in_the_browser() {
let manifest = include_str!("../Cargo.toml");
let eframe: Vec<&str> = manifest.lines().filter(|l| l.trim_start().starts_with("eframe =")).collect();
assert!(!eframe.is_empty(), "the app depends on eframe");
assert!(eframe.iter().any(|l| l.contains("\"links\"")), "eframe is built with `links`: {eframe:?}");
}
+70 -7
View File
@@ -14,6 +14,40 @@ use crate::{Result, bad};
/// Deepest params nesting the resolver walks before giving up.
const MAX_DEPTH: usize = 64;
/// How much referenced data (values and string bytes) the params of one step may copy in.
const MAX_EXPANDED: usize = 8 << 20;
/// What copying `v` costs: its values and string bytes, counting stops past `limit`.
fn weight(v: &Value, limit: usize) -> usize {
let mut total = 0usize;
let mut stack = vec![v];
while let Some(v) = stack.pop() {
total += 1;
match v {
Value::String(s) => total += s.len(),
// Checked before they are queued, so a huge list can't grow the worklist past the limit.
Value::Array(a) => {
if total + a.len() > limit {
return total + a.len();
}
stack.extend(a);
}
Value::Object(o) => {
total += o.keys().map(String::len).sum::<usize>();
if total + o.len() > limit {
return total + o.len();
}
stack.extend(o.values());
}
_ => {}
}
if total > limit {
return total;
}
}
total
}
/// One step of a batch: a command, its params and the name later steps can call it by.
#[derive(Clone, Debug, PartialEq)]
pub struct BatchStep {
@@ -99,30 +133,32 @@ impl BatchScope {
/// `params` of the next step with every reference in it replaced.
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
self.resolve_in(params, 0)
let mut left = MAX_EXPANDED;
self.resolve_in(params, 0, &mut left)
}
fn resolve_in(&self, v: &Value, depth: usize) -> Result<Value> {
/// `left`: how much referenced data one step's params may still copy in.
fn resolve_in(&self, v: &Value, depth: usize, left: &mut usize) -> Result<Value> {
let step = self.next_step();
if depth > MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o),
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o, left),
Value::Object(o) => {
let mut out = Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), self.resolve_in(x, depth + 1)?);
out.insert(k.clone(), self.resolve_in(x, depth + 1, left)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1, left)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for.
fn reference(&self, o: &Map<String, Value>) -> Result<Value> {
fn reference(&self, o: &Map<String, Value>, left: &mut usize) -> Result<Value> {
let step = self.next_step();
let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}"));
let key = if o.contains_key("$step") { "$step" } else { "$ref" };
@@ -153,7 +189,10 @@ impl BatchScope {
Some(_) => return Err(fail("`field` must be a string".into())),
};
let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?;
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
let value = lookup(result, path).map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))?;
// Counted before copying: a few references to one big result can't multiply it.
*left = left.checked_sub(weight(value, *left)).ok_or_else(|| fail("the references copy more than the batch may expand".into()))?;
Ok(value.clone())
}
/// Most step names a missing-reference error lists.
@@ -218,3 +257,27 @@ fn kind(v: &Value) -> &'static str {
Value::Object(_) => "an object",
}
}
#[cfg(test)]
mod expansion_tests {
use super::*;
use serde_json::json;
#[test]
fn references_copy_a_bounded_amount_into_one_step() {
let steps = [BatchStep::new("a", json!({})), BatchStep::new("b", json!({}))];
let mut scope = BatchScope::new(&steps).unwrap();
// A result of about 1 MB.
scope.push(json!({"ids": vec!["x".repeat(1000); 1000]}));
let refs = |n: usize| json!({"all": vec![json!({"$step": 0, "field": "ids"}); n]});
assert!(scope.resolve(&refs(4)).is_ok());
let err = scope.resolve(&refs(200)).unwrap_err().to_string();
assert!(err.contains("more than the batch may expand"), "{err}");
}
#[test]
fn a_huge_list_is_refused_before_it_is_queued() {
assert!(weight(&json!(vec![0; 100_000]), 1000) > 1000);
assert!(weight(&json!(vec![0; 10]), 1000) <= 1000);
}
}
+19 -8
View File
@@ -166,6 +166,14 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
/// A node's selected properties, applied at every level of its subtree.
pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value {
node_json_in(doc, id, depth, options, &GridCells::default())
}
/// The cells of the grid frames met so far in one inspection, so a grid's geometry is worked out
/// once, not once per child.
type GridCells = std::cell::RefCell<std::collections::HashMap<NodeId, std::collections::HashMap<NodeId, figcraft_layout::GridCell>>>;
fn node_json_in(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions, grids: &GridCells) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let mut v = json!({"id": n.id});
let Some(o) = v.as_object_mut() else { return v };
@@ -177,7 +185,7 @@ pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options:
if !n.is_page() {
base_props(doc, n, id, o, options);
paint_props(n, o, options);
layout_props(doc, n, o, options);
layout_props(doc, n, o, options, grids);
dev_props(doc, n, o, options);
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
@@ -190,7 +198,7 @@ pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options:
}
if !n.children.is_empty() {
if depth > 0 {
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect()));
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_in(doc, *c, depth - 1, options, grids)).collect()));
} else {
o.insert("childCount".into(), json!(n.children.len()));
}
@@ -287,7 +295,7 @@ fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &Inspe
}
}
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions, grids: &GridCells) {
if let Some(l) = &n.layout {
insert_prop(o, options, "layout", || camel(serde_json::to_value(l).unwrap_or(Value::Null)));
}
@@ -304,11 +312,14 @@ fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>
insert_prop(o, options, k, || json!(v));
}
}
if let Some(cell) = (parent_al && options.includes("gridCell"))
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
.flatten()
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
{
let cell = (parent_al && options.includes("gridCell")).then_some(n.parent).flatten().and_then(|p| {
let mut grids = grids.borrow_mut();
let cells = grids
.entry(p)
.or_insert_with(|| figcraft_layout::grid_geometry(doc, p).map(|g| g.cells.into_iter().map(|c| (c.id, c)).collect()).unwrap_or_default());
cells.get(&n.id).copied()
});
if let Some(cell) = cell {
insert_prop(o, options, "gridCell", || {
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v})
+3
View File
@@ -182,6 +182,9 @@ fn table_edits_add_and_remove_rows_and_columns_keeping_text() {
#[test]
fn a_double_click_types_in_the_cell_and_its_row_grows_with_the_text() {
// Measure with system fonts when the optional craft-fonts checkout is absent,
// without depending on another test having scanned them first.
figcraft_text::scan_system_fonts();
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap();
let t = id(&r, "id");
+14 -3
View File
@@ -174,7 +174,8 @@ pub(super) fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
// no undo step.
let mut work = s.doc()?.clone();
let doc = &mut work;
let ids = roots(doc, &ids);
// A locked layer (or one in a locked layer) ignores the arrow keys.
let ids: Vec<NodeId> = roots(doc, &ids).into_iter().filter(|i| !crate::tools::locked_in(doc, *i)).collect();
let mut changed = false;
// Children in an auto layout flow move one place instead (see `al_keys`).
for i in &ids {
@@ -209,6 +210,9 @@ pub(super) fn nudge_size(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("transform.nudgeSize", s, p)?;
let ids = roots(s.doc()?, &ids);
for i in ids {
if crate::tools::locked_in(s.doc()?, i) {
continue;
}
let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue };
let mut q = serde_json::Map::new();
q.insert("ids".into(), json!([i]));
@@ -231,9 +235,16 @@ pub(super) fn toggle(s: &mut Session, p: &Value, what: &str) -> Result<Value> {
// Figma: if any is visible, hide all (and vice versa).
let value = explicit.unwrap_or_else(|| match what {
"visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)),
_ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)),
// A child of a locked layer counts as locked: its own flag can't be toggled while the
// parent is locked.
_ => !ids.iter().all(|i| crate::tools::locked_in(doc, *i)),
});
for i in &ids {
// Lock/Unlock leaves a child of a locked layer alone, as its padlock does: its own flag must
// still hold once the parent is unlocked. An explicit `locked` (scripts) sets it anyway.
let under_lock = |i: NodeId| doc.ancestors(i).into_iter().any(|a| doc.node(a).is_some_and(|n| n.locked));
let skip: Vec<NodeId> =
if what == "locked" && explicit.is_none() { ids.iter().copied().filter(|i| under_lock(*i)).collect() } else { Vec::new() };
for i in ids.iter().filter(|i| !skip.contains(i)) {
let n = doc.get_mut(*i)?;
if what == "visible" {
n.visible = value;
+7
View File
@@ -270,6 +270,13 @@ mod tests {
assert_eq!(info["gridCell"]["column"], json!(2));
assert_eq!(info["gridCell"]["row"], json!(1));
assert_eq!(info["gridCell"]["anchored"], json!(true));
// Inspected together with the frame, each child still gets its own cell.
let all = crate::inspect::node_json(s.doc().unwrap(), f, 1);
let cells: Vec<(Value, Value)> =
all["children"].as_array().unwrap().iter().map(|c| (c["gridCell"]["column"].clone(), c["gridCell"]["row"].clone())).collect();
assert_eq!(cells[3], (json!(2), json!(1)));
assert_eq!(cells[0].0, json!(0));
assert!(cells.iter().all(|(c, r)| c.is_number() && r.is_number()), "{cells:?}");
ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": "auto", "gridRow": null}}));
assert_eq!(node(&s, kids[3]).child_layout.grid_column, None);
// Fewer columns drop the extra tracks.
+13
View File
@@ -285,3 +285,16 @@ fn vue_layer_trees_import_connected_components_in_script_setup() {
assert!(code.starts_with(want), "{code}");
assert_eq!(code.matches("import Card").count(), 1, "{code}");
}
#[test]
fn many_entries_for_one_component_each_link() {
let mut s = Session::new();
let (_, hover) = button(&mut s);
let links: Vec<Value> = (0..crate::code_connect::MAX_FILE_LINKS)
.map(|i| json!({"component": "Button", "language": if i % 2 == 0 { "react" } else { "vue" }, "name": "Button", "template": "<Button />"}))
.collect();
let r = ex(&mut s, "codeConnect.import", json!({"json": {"version": 1, "links": links}}));
assert_eq!(r["linked"], crate::code_connect::MAX_FILE_LINKS, "{r}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": hover, "x": 0, "y": 500})));
assert_eq!(code(&mut s, i, "react"), "<Button />\n");
}
+9 -4
View File
@@ -669,7 +669,12 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
let doc = s.doc()?;
// Components and sets by name (variants are linked through their set).
let owners: Vec<(String, NodeId)> = doc.nodes().filter(|n| owner(doc, n.id) == Some(n.id)).map(|n| (n.name.clone(), n.id)).collect();
let mut owners: std::collections::HashMap<&str, Vec<NodeId>> = std::collections::HashMap::new();
for n in doc.nodes().filter(|n| owner(doc, n.id) == Some(n.id)) {
owners.entry(n.name.as_str()).or_default().push(n.id);
}
// What each component looks like, worked out once however many entries name it.
let mut described: std::collections::HashMap<NodeId, Option<Component>> = std::collections::HashMap::new();
let mut warnings: Vec<String> = Vec::new();
let mut apply: Vec<(NodeId, CodeLink)> = Vec::new();
for (i, e) in entries.iter().enumerate() {
@@ -689,13 +694,13 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
name: name.into(),
template: template.into(),
};
let matches: Vec<NodeId> = owners.iter().filter(|(n, _)| n == component).map(|(_, id)| *id).collect();
let matches = owners.get(component).cloned().unwrap_or_default();
if matches.is_empty() {
warnings.push(format!("link {}: no component named {component:?}", i + 1));
}
for id in matches {
let Some(c) = main_of(doc, id).and_then(|m| describe(doc, m)) else { continue };
match check(&c, &link, lang) {
let Some(c) = described.entry(id).or_insert_with(|| main_of(doc, id).and_then(|m| describe(doc, m))).as_ref() else { continue };
match check(c, &link, lang) {
Ok(_) => apply.push((id, link.clone())),
Err(err) => warnings.push(format!("link {} ({component}): {err}", i + 1)),
}
+2
View File
@@ -419,6 +419,8 @@ mod live_signal_tests;
#[cfg(test)]
mod live_tests;
#[cfg(test)]
mod lock_behaviour_tests;
#[cfg(test)]
mod mask_tests;
#[cfg(test)]
mod masks_behaviour_tests;
+141
View File
@@ -0,0 +1,141 @@
//! Locked layers, as measured in Figma's web app (2026-11-10) and described in the help-center
//! article "Lock and unlock layers": the canvas never selects a locked layer or a child of a locked
//! frame; a locked layer selected in the layers panel has no handles and ignores the arrow keys, but
//! its properties still edit; a child of a locked frame can't be locked or unlocked on its own.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind, handle_at};
use figcraft_doc::NodeId;
use figcraft_geom::Point;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id_of(&ex(s, "shape.rectangle", p)["id"]).unwrap()
}
/// A frame `f` (0,0 300x200) holding rectangle `d` (20,20 40x40); a locked rectangle `lk`
/// (400,0 40x40) and an unlocked one `r` (400,100 40x40) on the page.
fn scene(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200}))["id"]).unwrap();
let d = rect(s, 20.0, 20.0, Some(f));
let lk = rect(s, 400.0, 0.0, None);
let r = rect(s, 400.0, 100.0, None);
ex(s, "object.toggleLock", json!({"ids": [lk], "locked": true}));
(f, d, lk, r)
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
let mut e = PointerEvent::at(kind, x, y, 1.0);
e.cmd = cmd;
e
}
fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap();
}
fn origin(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
fn size(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.width(), b.height())
}
fn locked(s: &Session, id: NodeId) -> bool {
s.doc().unwrap().node(id).unwrap().locked
}
#[test]
fn canvas_never_selects_a_locked_layer_or_a_locked_frames_child() {
let mut s = Session::new();
let (f, d, _lk, _r) = scene(&mut s);
click(&mut s, 420.0, 20.0, false);
assert!(s.selection().is_empty(), "a click on a locked layer selects nothing");
click(&mut s, 420.0, 20.0, true);
assert!(s.selection().is_empty(), "Cmd/Ctrl-click on a locked layer selects nothing");
s.pointer(&ev(PointerKind::Down, 390.0, -10.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, 450.0, 50.0, false)).unwrap();
s.pointer(&ev(PointerKind::Up, 450.0, 50.0, false)).unwrap();
assert!(s.selection().is_empty(), "a marquee over a locked layer selects nothing");
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
click(&mut s, 30.0, 30.0, false);
assert!(!s.selection().contains(&d), "a click on a child of a locked frame doesn't select it");
click(&mut s, 30.0, 30.0, true);
assert!(s.selection().is_empty(), "Cmd/Ctrl-click on a child of a locked frame selects nothing");
}
#[test]
fn a_panel_selected_locked_layer_has_no_handles_ignores_arrows_and_takes_property_edits() {
let mut s = Session::new();
let (_f, _d, lk, _r) = scene(&mut s);
ex(&mut s, "select.set", json!({"ids": [lk]}));
assert_eq!(handle_at(s.doc().unwrap(), &[lk], Point::new(400.0, 0.0), 1.0), None, "no handles on a locked layer");
let steps = s.undo_depth();
for _ in 0..3 {
ex(&mut s, "transform.nudge", json!({"dx": 1}));
}
assert_eq!(origin(&s, lk), (400.0, 0.0), "the arrow keys don't move a locked layer");
ex(&mut s, "transform.nudgeSize", json!({"dw": 1}));
assert_eq!(size(&s, lk), (40.0, 40.0), "the resize arrows don't resize a locked layer");
assert_eq!(s.undo_depth(), steps, "an arrow that changes nothing adds no undo step");
ex(&mut s, "node.set", json!({"id": lk, "props": {"x": 300}}));
assert_eq!(origin(&s, lk).0, 300.0, "the X field still moves a locked layer");
let before = format!("{:?}", s.doc().unwrap().node(lk).unwrap().fills);
ex(&mut s, "fill.set", json!({"ids": [lk], "color": "#ff0000"}));
assert_ne!(format!("{:?}", s.doc().unwrap().node(lk).unwrap().fills), before, "a fill still edits");
assert_eq!(s.selection(), vec![lk], "the selection stays the locked layer");
}
#[test]
fn a_child_of_a_locked_frame_cannot_be_locked_or_unlocked_on_its_own() {
let mut s = Session::new();
let (f, d, _lk, _r) = scene(&mut s);
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
ex(&mut s, "object.toggleLock", json!({"ids": [d]}));
assert!(!locked(&s, d), "toggling a child of a locked frame doesn't lock the child");
ex(&mut s, "select.set", json!({"ids": [d]}));
ex(&mut s, "object.toggleLock", json!({}));
assert!(!locked(&s, d), "Lock/Unlock on a panel-selected child of a locked frame does nothing");
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": false}));
click(&mut s, 30.0, 30.0, false);
assert_eq!(s.selection(), vec![d], "after unlocking the frame a click selects the child");
assert!(!locked(&s, d));
}
#[test]
fn lock_unlock_on_a_mixed_selection_leaves_a_locked_frames_child_alone() {
let mut s = Session::new();
let (f, d, _lk, r) = scene(&mut s);
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
ex(&mut s, "select.set", json!({"ids": [d, r]}));
ex(&mut s, "object.toggleLock", json!({}));
assert!(locked(&s, r), "the unlocked layer in the selection is locked");
assert!(!locked(&s, d), "the locked frame's child keeps its own flag");
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": false}));
click(&mut s, 30.0, 30.0, false);
assert_eq!(s.selection(), vec![d], "so unlocking the frame frees the child");
}
#[test]
fn ctrl_shift_l_on_a_panel_selected_locked_layer_unlocks_it() {
let mut s = Session::new();
let (_f, _d, lk, _r) = scene(&mut s);
ex(&mut s, "select.set", json!({"ids": [lk]}));
ex(&mut s, "object.toggleLock", json!({}));
assert!(!locked(&s, lk), "Lock/Unlock unlocks a locked layer");
assert!(handle_at(s.doc().unwrap(), &[lk], Point::new(400.0, 0.0), 1.0).is_some(), "handles return");
}
+26
View File
@@ -99,3 +99,29 @@ fn overlapping_rectangle_bounds_count_self_and_cross_pairs() {
let shapes = rects([Rect::new(0.0, 0.0, 2.0, 2.0), Rect::new(1.0, 1.0, 3.0, 3.0), Rect::new(4.0, 4.0, 5.0, 5.0)]);
assert_eq!(complexity([&shapes]), Some(Complexity { segments: 12, crossings: 14 }));
}
/// Open L shapes whose segments never overlap but whose implicit closing edges all do.
#[test]
fn faces_checks_the_closed_inputs_before_normalising() {
let mut p = BezPath::new();
for i in 0..2200 {
let (x, y) = (-(i as f64), 10_000.0 + i as f64);
p.move_to((x, 0.0));
p.line_to((x, y));
p.line_to((10_000.0, y));
}
assert!(complexity([&p]).is_some_and(|c| c.crossings <= MAX_CROSSINGS), "open input passes");
assert!(matches!(faces(&[(p, false)]), Err(PathOpsError::TooComplex)));
}
#[test]
fn closing_drops_move_only_subpaths() {
let mut p = BezPath::new();
for i in 0..1000 {
p.move_to((i as f64, 0.0));
}
assert!(crate::closed(&p).elements().is_empty());
p.line_to((5.0, 5.0));
let c = crate::closed(&p);
assert_eq!(c.elements().len(), 3, "the last move, the line and the close: {:?}", c.elements());
}
+14 -4
View File
@@ -57,20 +57,27 @@ impl std::fmt::Display for PathOpsError {
}
}
/// Close every open subpath (a filled open path paints as if closed).
/// Close every open subpath (a filled open path paints as if closed). A subpath of a lone move
/// fills nothing and is left out.
pub fn closed(path: &BezPath) -> BezPath {
let mut out = BezPath::new();
let mut open = false;
let mut pending: Option<PathEl> = None;
for el in path.elements() {
match el {
PathEl::MoveTo(_) => {
if open {
out.close_path();
}
open = true;
open = false;
pending = Some(*el);
continue;
}
PathEl::ClosePath => open = false,
_ => {}
_ => open = true,
}
if let Some(m) = pending.take() {
out.push(m);
}
out.push(*el);
}
@@ -240,7 +247,10 @@ pub fn faces(paths: &[(BezPath, bool)]) -> Result<Vec<ArrangementFace>, PathOpsE
// corner folds over itself and touches the shape's fill) leave the winding on each side of a
// segment ambiguous where they touch, which made the sweep report overlapping faces. Reducing
// every input to the simple contours of the area it fills first keeps the faces disjoint.
let closed_paths: Vec<BezPath> = paths.iter().map(|(p, even_odd)| plain(&closed(p), *even_odd)).collect();
// Closing adds edges, so what `plain` sweeps is checked first.
let closed_inputs: Vec<BezPath> = paths.iter().map(|(p, _)| closed(p)).collect();
budget::check(&closed_inputs)?;
let closed_paths: Vec<BezPath> = closed_inputs.iter().zip(paths).map(|(p, (_, even_odd))| plain(p, *even_odd)).collect();
if closed_paths.iter().all(|p| p.elements().is_empty()) {
return Ok(Vec::new());
}
+15 -2
View File
@@ -66,10 +66,23 @@ impl Styled {
}
// Each point's own join: a short corner along its two segments stroked with that join.
let reach = (width * 0.05).max(0.2);
// Segments per point, built once (a scan of every segment per point is quadratic); like `incident`, a loop segment counts at its `a` end only.
// Only points that draw a join of their own get a list.
let mut incident: std::collections::HashMap<usize, Vec<(usize, bool)>> =
(0..self.net.vertices.len()).filter(|v| self.breaks(*v)).map(|v| (v, Vec::new())).collect();
for (i, s) in self.net.segments.iter().enumerate() {
if let Some(list) = incident.get_mut(&s.a) {
list.push((i, true));
}
if s.b != s.a
&& let Some(list) = incident.get_mut(&s.b)
{
list.push((i, false));
}
}
for (v, x) in self.net.vertices.iter().enumerate() {
let Some(join) = x.join.filter(|_| self.breaks(v)) else { continue };
let inc = self.net.incident(v);
let [(s1, a1), (s2, a2)] = inc.as_slice() else { continue };
let Some([(s1, a1), (s2, a2)]) = incident.get(&v).map(Vec::as_slice) else { continue };
let (Some(d1), Some(d2)) = (self.net.tangent_out(*s1, *a1), self.net.tangent_out(*s2, *a2)) else { continue };
let p = x.point();
let mut corner = BezPath::new();
+14 -4
View File
@@ -262,7 +262,12 @@ const DASH_ACCURACY: f64 = 0.05;
/// Whether `pattern` (from [`dash_pattern`]) makes at most [`MAX_DASHES`] dashes along `len`;
/// past that a stroke draws solid instead of one outline per dash (#1060).
pub(crate) fn dashes_fit(len: f64, pattern: &[f64]) -> bool {
len / pattern.iter().sum::<f64>() * (pattern.len() as f64) < MAX_DASHES as f64
dash_count(len, pattern) < MAX_DASHES as f64
}
/// About how many dashes `pattern` makes along `len`.
fn dash_count(len: f64, pattern: &[f64]) -> f64 {
len / pattern.iter().sum::<f64>() * (pattern.len() as f64)
}
/// Resample a polyline so no step is longer than `max_step`; returns the points and the length at
@@ -303,6 +308,8 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start, cap_end };
let pattern = dash_pattern(props);
let mut budget = MAX_WIDTH_SAMPLES;
// The dashes of all subpaths together stay within MAX_DASHES; the rest draw solid.
let mut dash_budget = MAX_DASHES as f64;
let mut out = BezPath::new();
for line in &lines {
let len = line.length();
@@ -317,9 +324,12 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
[0.5 * width * right, 0.5 * width * left]
})
.collect();
match &pattern {
Some(pat) if dashes_fit(len, pat) => {
let (pat, offset) = line_dashes(len, line.closed, pat);
// Budgeted by the pattern as fitted to this line (a short line still takes a whole pattern).
let fit = pattern.as_ref().map(|pat| line_dashes(len, line.closed, pat));
let used = fit.as_ref().map_or(0.0, |(pat, _)| dash_count(len, pat).max(pat.len() as f64));
match fit {
Some((pat, offset)) if used < dash_budget && dashes_fit(len, &pat) => {
dash_budget -= used;
for (p, h) in dashes(&pts, &hw, &pat, offset) {
outline_piece(&mut out, &p, &h, false, style);
}
+28
View File
@@ -145,3 +145,31 @@ fn hostile_and_huge_input_is_bounded() {
let s = stroke_outline(&line(1e6), &tiny, false);
assert!(s.elements().len() < 1_000_000);
}
#[test]
fn dashes_of_all_subpaths_share_one_allowance() {
let tiny = StrokeProps { dashes: vec![1.0, 1.0], ..props(1.0, Some(WidthProfile::uniform())) };
let many = |n: usize| {
let mut p = BezPath::new();
for i in 0..n {
p.move_to((0.0, i as f64 * 4.0));
p.line_to((15_000.0, i as f64 * 4.0));
}
variable_outline(&p, 1.0, &tiny, &WidthProfile::uniform()).unwrap().elements().len()
};
// The later subpaths draw solid once the first has used most of the allowance.
assert!(many(10) < 3 * many(1), "{} vs {}", many(10), many(1));
}
#[test]
fn a_short_subpath_is_budgeted_by_the_whole_pattern_it_takes() {
// 100 one-unit lines, each fitted to a 4,000-entry pattern: ~400,000 dashes if not budgeted.
let long = StrokeProps { dashes: vec![1.0; 4000], ..props(1.0, Some(WidthProfile::uniform())) };
let mut p = BezPath::new();
for i in 0..100 {
p.move_to((0.0, i as f64 * 4.0));
p.line_to((1.0, i as f64 * 4.0));
}
let n = variable_outline(&p, 1.0, &long, &WidthProfile::uniform()).unwrap().elements().len();
assert!(n < 20_000 * 12, "{n}");
}
+8
View File
@@ -91,3 +91,11 @@ fn kept_splat_answers_are_the_answers_worked_out_afresh() {
}
}
}
#[test]
fn ids_chosen_to_differ_only_in_high_bits_still_spread_over_buckets() {
use std::hash::BuildHasher;
let state = crate::draw::IdState::default();
let buckets: std::collections::HashSet<u64> = (0..4096u64).map(|k| state.hash_one(NodeId(k << 40)) & 0xfff).collect();
assert!(buckets.len() > 2000, "{}", buckets.len());
}
+25 -4
View File
@@ -69,16 +69,37 @@ impl<'a> Bounds<'a> {
}
/// A hash map keyed by node ids, which need no SipHash: they are counters (or a peer's range of
/// them), and SipHash took a good part of a render.
type IdMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<IdHash>>;
/// them), and SipHash took a good part of a render. A file can choose its ids, so each map is
/// seeded at random and the hash mixes its high bits down (ids differing only up high would
/// otherwise share a bucket).
type IdMap<K, V> = HashMap<K, V, IdState>;
/// Builds [`IdHash`]es from a per-map random seed.
#[derive(Clone)]
pub(crate) struct IdState(u64);
impl Default for IdState {
fn default() -> Self {
use std::hash::BuildHasher;
IdState(std::collections::hash_map::RandomState::new().hash_one(0u8))
}
}
impl std::hash::BuildHasher for IdState {
type Hasher = IdHash;
fn build_hasher(&self) -> IdHash {
IdHash(self.0)
}
}
/// Mixes each word in with a multiply (Fibonacci hashing), so ids that differ in low bits spread.
#[derive(Default)]
pub(crate) struct IdHash(u64);
impl Hasher for IdHash {
fn finish(&self) -> u64 {
self.0
// The multiply's low bits depend only on the input's low bits: mix the high ones down.
let h = (self.0 ^ (self.0 >> 32)).wrapping_mul(0xFF51_AFD7_ED55_8CCD);
h ^ (h >> 29)
}
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
+4
View File
@@ -39,6 +39,10 @@ pub fn nested<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
/// the pixmap, the cell and the pixmap's pixels per unit on each axis (whole pixels per cell, so
/// the repeat lines up exactly).
pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64, time: f64) -> Option<(Pixmap, PatternCell, (f64, f64))> {
// Past the nesting limit nothing is drawn, so don't allocate a cell first.
if DEPTH.with(Cell::get) >= MAX_DEPTH {
return None;
}
let src = doc.node(NodeId(pt.source)).filter(|n| !n.is_page())?;
let cell = pt.cell(src.width, src.height, w, h)?;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
+10
View File
@@ -57,3 +57,13 @@ fn a_transparent_images_inner_shadow_is_an_embedded_render() {
let svg = export(&d, n);
assert!(svg.contains("<pattern") && svg.contains("<mask"), "an opaque picture stays vector, shaded from its box: {svg}");
}
#[test]
fn paints_of_one_image_embed_its_bytes_once() {
let (mut d, n) = scene(false, EffectKind::DropShadow(shadow()));
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::image("svg-image-shadow-opaque", ImageScaleMode::Fill); 3];
let svg = export(&d, n);
assert_eq!(svg.matches("<image id=").count(), 1, "{svg}");
assert_eq!(svg.matches("<pattern").count(), 3);
}
+13 -5
View File
@@ -70,10 +70,15 @@ impl Img {
}
}
fn stack(bottom: Img, top: Img) -> Img {
/// The most layers a stack may hold. Results that name earlier results can double a stack at each
/// of up to 64 primitives; real shadow chains hold a handful of layers, and a longer stack isn't
/// an effect list anyone wrote.
const MAX_STACK: usize = 64;
fn stack(bottom: Img, top: Img) -> Option<Img> {
let mut v = bottom.into_layers();
v.extend(top.into_layers());
Img::Stack(v)
(v.len() <= MAX_STACK).then_some(Img::Stack(v))
}
/// The effects `filters` amount to on a layer whose user space maps to the page by `world`; `None`
@@ -148,13 +153,16 @@ fn primitive(k: &Kind, get: &dyn Fn(&Input) -> Option<Img>) -> Option<Img> {
let mut v = Vec::new();
for i in m.inputs() {
v.extend(get(i)?.into_layers());
if v.len() > MAX_STACK {
return None;
}
}
Img::Stack(v)
}
Kind::Blend(b) => {
let (top, bottom) = (get(b.input1())?, get(b.input2())?);
match b.mode() {
usvg::BlendMode::Normal => stack(bottom, top),
usvg::BlendMode::Normal => stack(bottom, top)?,
_ => return None,
}
}
@@ -162,7 +170,7 @@ fn primitive(k: &Kind, get: &dyn Fn(&Input) -> Option<Img>) -> Option<Img> {
let sigma = (f64::from(d.std_dev_x().get()) + f64::from(d.std_dev_y().get())) / 2.0;
let s = Sh { dx: f64::from(d.dx()), dy: f64::from(d.dy()), sigma, color: color(d.color(), d.opacity().get()), ..Sh::alpha() };
match get(d.input())? {
Img::Layer(b) => stack(Img::Shadow(s), Img::Layer(b)),
Img::Layer(b) => stack(Img::Shadow(s), Img::Layer(b))?,
_ => return None,
}
}
@@ -190,7 +198,7 @@ fn tint(v: &[f32], s: Sh) -> Option<Img> {
fn composite(a: Img, b: Img, op: CompositeOperator) -> Option<Img> {
let tinted = |s: Sh, c: Color| Sh { color: Color::new(c.r, c.g, c.b, (c.a * s.color.a).clamp(0.0, 1.0)), ..s };
Some(match (op, a, b) {
(CompositeOperator::Over, a, b) => stack(b, a),
(CompositeOperator::Over, a, b) => stack(b, a)?,
(CompositeOperator::In, Img::Flood(c), Img::Shadow(s)) | (CompositeOperator::In, Img::Shadow(s), Img::Flood(c)) => Img::Shadow(tinted(s, c)),
// A flood cut to everything but a shadow, then cut to the layer: an inner shadow.
(CompositeOperator::Out, Img::Flood(c), Img::Shadow(s)) if !s.inner && !s.inverted => Img::Shadow(Sh { inverted: true, ..tinted(s, c) }),
+12
View File
@@ -228,3 +228,15 @@ fn coloured_copies_of_a_blurred_layer_arent_shadows() {
assert!(!notes.is_empty(), "{filter}: reported");
}
}
#[test]
fn a_doubling_chain_of_merges_is_refused_not_expanded() {
// Each merge holds the previous result twice: 2^40 layers if expanded.
let mut f = String::from(r##"<feMerge result="r0"><feMergeNode in="SourceGraphic"/><feMergeNode in="SourceGraphic"/></feMerge>"##);
for i in 1..40 {
f.push_str(&format!(r##"<feMerge result="r{i}"><feMergeNode in="r{}"/><feMergeNode in="r{}"/></feMerge>"##, i - 1, i - 1));
}
let (fx, notes) = effects(&f, "");
assert!(fx.is_empty(), "{fx:?}");
assert!(!notes.is_empty());
}
+26 -13
View File
@@ -93,6 +93,8 @@ struct W<'a> {
used_ids: std::collections::BTreeSet<String>,
/// The next `_k` suffix to try per base id, so repeated names don't rescan from `_2`.
next_suffix: std::collections::HashMap<String, usize>,
/// The `<image>` written to `defs` per image key, so paints of one image share its bytes.
images: std::collections::HashMap<String, String>,
}
/// ` clip-path="url(#…)"` for a clip path id (else nothing).
@@ -269,17 +271,29 @@ impl W<'_> {
PaintKind::Image(img) => {
let data = self.doc.images.get(&img.image)?;
let pid = self.id("pattern");
let iid = self.id("image");
// Formats SVG viewers don't show (TIFF, BMP) go in as PNG.
let png;
let (mime, bytes) = match sniff_mime(&data.bytes) {
"application/octet-stream" => {
png = figcraft_render::images::decode(&data.bytes).and_then(|p| figcraft_render::encode_png(&p).ok())?;
("image/png", png.as_slice())
let iid = match self.images.get(&img.image) {
Some(iid) => iid.clone(),
None => {
let iid = self.id("image");
// Formats SVG viewers don't show (TIFF, BMP) go in as PNG.
let png;
let (mime, bytes) = match sniff_mime(&data.bytes) {
"application/octet-stream" => {
png = figcraft_render::images::decode(&data.bytes).and_then(|p| figcraft_render::encode_png(&p).ok())?;
("image/png", png.as_slice())
}
m => (m, data.bytes.as_slice()),
};
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let _ = write!(
self.defs,
"<image id=\"{iid}\" width=\"{}\" height=\"{}\" href=\"data:{mime};base64,{b64}\"/>",
data.width, data.height
);
self.images.insert(img.image.clone(), iid.clone());
iid
}
m => (m, data.bytes.as_slice()),
};
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let t = figcraft_render::paint::image_transform(img, f64::from(data.width), f64::from(data.height), w, h);
let m = Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)]);
let (pw, ph) = if img.scale_mode == figcraft_color::ImageScaleMode::Tile {
@@ -289,12 +303,10 @@ impl W<'_> {
};
let _ = write!(
self.defs,
"<pattern id=\"{pid}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"{}\"><image id=\"{iid}\" width=\"{}\" height=\"{}\" href=\"data:{mime};base64,{b64}\"/></pattern>",
"<pattern id=\"{pid}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"{}\"><use href=\"#{iid}\"/></pattern>",
num(pw),
num(ph),
matrix(m),
data.width,
data.height
matrix(m)
);
let op = if p.opacity < 0.999 { format!(" fill-opacity=\"{}\"", num(f64::from(p.opacity))) } else { String::new() };
Some(format!("fill=\"url(#{pid})\"{op}{blend}"))
@@ -1148,6 +1160,7 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu
clip: None,
used_ids: Default::default(),
next_suffix: Default::default(),
images: Default::default(),
};
for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
+1
View File
@@ -294,6 +294,7 @@ fn unique_id_is_linear_for_repeated_names() {
clip: None,
used_ids: Default::default(),
next_suffix: Default::default(),
images: Default::default(),
};
assert_eq!(w.unique_id("a"), "a");
assert_eq!(w.unique_id("a_3"), "a_3");
@@ -252,6 +252,54 @@ fn sweeping_across_eyes_and_padlocks_sets_several_layers() {
assert!(!u.app.session.doc().unwrap().node(a).unwrap().visible, "a click on the eye hides that layer");
}
#[test]
fn the_padlock_of_a_layer_in_a_locked_frame_is_inert() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"name": "Locked frame"})).unwrap());
let child = rect(&mut s, "child", 0.0, Some(frame));
s.execute("select.none", &json!({})).unwrap();
s.execute("object.toggleLock", &json!({"ids": [frame], "locked": true})).unwrap();
let mut u = Ui::new(s);
// Selecting the child reveals its row; then the selection goes away.
u.app.run("select.set", json!({"ids": [child]}));
u.frame(vec![]);
u.app.run("select.none", json!({}));
u.frame(vec![]);
let steps = u.app.session.undo_depth();
let r = u.row_at(child);
// The padlock column (see the sweep test above).
click(&mut u, Pos2::new(250.0, r.y), Modifiers::NONE);
assert!(!u.app.session.doc().unwrap().node(child).unwrap().locked, "the child's padlock doesn't lock it");
assert_eq!(u.app.session.undo_depth(), steps, "and adds no undo step");
}
#[test]
fn a_padlock_sweep_skips_the_children_of_a_locked_frame() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"name": "Locked frame"})).unwrap());
let c1 = rect(&mut s, "child 1", 0.0, Some(frame));
let c2 = rect(&mut s, "child 2", 20.0, Some(frame));
let sibling = rect(&mut s, "sibling", 40.0, None);
s.execute("select.none", &json!({})).unwrap();
s.execute("object.toggleLock", &json!({"ids": [frame], "locked": true})).unwrap();
let mut u = Ui::new(s);
// Selecting a child reveals the frame's rows; then the selection goes away.
u.app.run("select.set", json!({"ids": [c1]}));
u.frame(vec![]);
u.app.run("select.none", json!({}));
u.frame(vec![]);
let padlock = 250.0;
let (rs, rc) = (u.row_at(sibling), u.row_at(c1));
let from = Pos2::new(padlock, rs.y);
u.frame(vec![Event::PointerMoved(from)]);
u.drag_to(from, Pos2::new(padlock, rc.y));
u.button(Pos2::new(padlock, rc.y), false);
let doc = u.app.session.doc().unwrap();
assert!(doc.node(sibling).unwrap().locked, "the sibling is locked by the sweep");
assert!(!doc.node(c1).unwrap().locked, "a child of a locked frame keeps its own flag");
assert!(!doc.node(c2).unwrap().locked, "so does the other child");
}
#[test]
fn renaming_several_layers_opens_the_rename_dialog() {
let (mut u, [a, b, c, _d]) = four();
+15 -6
View File
@@ -972,6 +972,8 @@ fn eye_lock(
// A sweep across the eyes or padlocks takes in the rows the pointer passes.
if let Some(sw) = sweep.as_mut()
&& !sw.ids.contains(&row.id)
// A padlock sweep leaves the children of a locked layer alone (their padlock is inert).
&& !(sw.lock && row.locked_above)
&& ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.y_range().contains(p.y))
{
sw.ids.push(row.id);
@@ -1003,13 +1005,20 @@ fn eye_lock(
icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col);
}
if show_lock && !dev {
let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), Sense::click_and_drag());
// A child of a locked layer can't be locked or unlocked on its own while the parent is
// locked (Lock and unlock layers): its padlock is inert.
let sense = if row.locked_above { Sense::hover() } else { Sense::click_and_drag() };
let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), sense);
a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above);
if lresp.clicked() {
lock = Some(row.id);
}
if lresp.drag_started() {
*sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] });
if row.locked_above {
a11y::disabled(&lresp);
} else {
if lresp.clicked() {
lock = Some(row.id);
}
if lresp.drag_started() {
*sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] });
}
}
}
(vis, lock)
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.figcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
+4 -3
View File
File diff suppressed because one or more lines are too long
@@ -17,6 +17,8 @@ runtime-version: '26.08'
sdk: org.freedesktop.Sdk
command: figcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -22,6 +22,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: figcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland