Release 0.15.1

This commit is contained in:
Dylan M. Taylor committed 2026-11-04 19:47:28 -05:00
1 parent f1cd201c88
commit d5c46d6b5a
94 files changed
+2022 -205

No files matched your search

+1
View File
@@ -48,3 +48,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+2 -1
View File
@@ -498,7 +498,8 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(failed)?;
// `create_dir`, not `create_dir_all`: a directory or link already at this predictable name is refused.
std::fs::create_dir(&dir).map_err(failed)?;
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
let mut lib_s = Session::with_document(lib_doc);
@@ -75,3 +75,11 @@ fn codes_are_found_through_the_index_as_before() {
assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0);
}
#[test]
fn a_name_taken_from_the_file_is_cleaned_like_a_given_one() {
let dir = TempDir::new("embedded-name");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("", &snapshot("Bad\u{1b}[2Jname\u{202E}")).unwrap();
assert_eq!(file.name, "Bad[2Jname");
}
+2 -1
View File
@@ -95,8 +95,9 @@ impl Store {
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
}
let mut doc = replica.document().clone();
// The name the file keeps, whichever one it comes from, is cleaned.
let name = match clean_name(name) {
n if n.is_empty() => doc.name.clone(),
n if n.is_empty() => clean_name(&doc.name),
n => n,
};
doc.name = name.clone();
+3
View File
@@ -98,3 +98,6 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.aleksei-suholita]
real_name = "Oleksii Sukholita"
+11
View File
@@ -333,6 +333,17 @@ impl Change {
}
impl Entry {
/// The synced list this entry belongs to (`None` for entries kept in maps).
pub(crate) fn family(&self) -> Option<Family> {
match self {
Entry::Style(_) => Some(Family::Styles),
Entry::Comment(_) => Some(Family::Comments),
Entry::Collection(_) => Some(Family::Collections),
Entry::Variable(_) => Some(Family::Variables),
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
/// session carry their creator's client id, ids from before and ids of other forms are in
/// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by
+38 -7
View File
@@ -95,7 +95,8 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
let mut changes = fields;
let new: BTreeSet<NodeId> = added.iter().copied().collect();
// Creations, parents before children.
added.sort_by_cached_key(|id| (depth(to, *id), *id));
let mut memo = BTreeMap::new();
added.sort_by_cached_key(|id| (depth(to, *id, &mut memo), *id));
for id in &added {
if let Some(n) = to.node(*id) {
changes.push(create_change(n, replica.key(*id))?);
@@ -121,18 +122,30 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
Ok(Diff { changes, replica })
}
/// Depth of `id` in `doc` (pages are 0).
fn depth(doc: &Document, id: NodeId) -> usize {
let mut d = 0;
/// Depth of `id` in `doc` (pages are 0). `memo` holds the depths found so far, so a deep chain of
/// new layers is walked once rather than once per layer.
fn depth(doc: &Document, id: NodeId, memo: &mut BTreeMap<NodeId, usize>) -> usize {
let mut chain = vec![id];
let mut base = 0;
let mut cur = doc.node(id).and_then(|n| n.parent);
while let Some(p) = cur {
d += 1;
if d > 100_000 {
if let Some(d) = memo.get(&p) {
base = *d + 1;
break;
}
// An acyclic chain is no longer than the document: this only stops a cycle.
if chain.len() > doc.node_count() {
break;
}
chain.push(p);
cur = doc.node(p).and_then(|n| n.parent);
}
d
// `chain` runs from `id` up; the last entry's depth is `base` when it ends at a root or a memo hit.
let top = chain.len();
for (i, c) in chain.iter().enumerate() {
memo.insert(*c, base + (top - 1 - i));
}
memo.get(&id).copied().unwrap_or(0)
}
/// New keys for the children `kids` (in their new order) whose `old` key no longer fits: the
@@ -196,3 +209,21 @@ fn increasing_run(keys: &[Option<&str>]) -> Vec<bool> {
}
kept
}
#[cfg(test)]
mod depth_tests {
use super::*;
use figcraft_doc::NodeKind;
#[test]
fn a_chain_longer_than_a_fixed_bound_is_walked_whole() {
let mut doc = Document::new("File");
let mut parent = doc.first_page().unwrap();
let mut last = parent;
for i in 0..100_050 {
last = doc.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 1.0, 1.0).unwrap();
parent = last;
}
assert_eq!(depth(&doc, last, &mut BTreeMap::new()), 100_050);
}
}
+11
View File
@@ -310,3 +310,14 @@ fn many_sibling_placements_keep_the_order_of_counting_them() {
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
}
}
#[test]
fn a_chain_of_new_nested_layers_is_created_parents_first() {
let (a, _, frame, _) = setup();
let mut b = a.document().clone();
let mut parent = frame;
for i in 0..400 {
parent = b.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 10.0, 10.0).unwrap();
}
assert_eq!(round_trip(&a, &b).iter().filter(|c| matches!(c, Change::Create { .. })).count(), 400);
}
+123 -29
View File
@@ -54,18 +54,71 @@ struct List<T: Keyed> {
entry: fn(T::Key) -> Entry,
get: fn(&Document) -> &Vec<T>,
get_mut: fn(&mut Document) -> &mut Vec<T>,
/// The list's slot in an [`Index`].
index: fn(&mut Index) -> &mut Option<KeyIndex<T::Key>>,
}
const STYLES: List<Style> =
List { whole: DocProp::Styles, order: DocProp::StyleOrder, entry: Entry::Style, get: |d| &d.styles, get_mut: |d| &mut d.styles };
const COMMENTS: List<Comment> =
List { whole: DocProp::Comments, order: DocProp::CommentOrder, entry: Entry::Comment, get: |d| &d.comments, get_mut: |d| &mut d.comments };
/// Where the entries of each keyed list sit, kept while one batch is applied: a batch can put
/// hundreds of thousands of new entries, and finding each one's place by a scan of the growing
/// list would cost the square of that on the thread every session shares.
#[derive(Default)]
pub(crate) struct Index {
styles: Option<KeyIndex<String>>,
comments: Option<KeyIndex<u64>>,
collections: Option<KeyIndex<String>>,
variables: Option<KeyIndex<String>>,
}
/// The position of the first entry with each key in one list, and the list's length when it was
/// last brought up to date: a list whose length changed since is indexed again.
struct KeyIndex<K> {
at: BTreeMap<K, usize>,
len: usize,
}
impl<K: Ord> KeyIndex<K> {
fn of<T: Keyed<Key = K>>(list: &[T]) -> Self {
let mut at = BTreeMap::new();
for (i, e) in list.iter().enumerate() {
at.entry(e.key()).or_insert(i);
}
KeyIndex { at, len: list.len() }
}
/// The index in `slot`, built again from `list` unless its length and its place for `key`
/// still match the list, so a list changed some other way is never trusted.
fn current<'a, T: Keyed<Key = K>>(slot: &'a mut Option<Self>, list: &[T], key: &K) -> &'a mut Self {
let stale = |ix: &Self| ix.len != list.len() || ix.at.get(key).is_some_and(|&i| list.get(i).is_none_or(|e| e.key() != *key));
if slot.as_ref().is_none_or(stale) {
*slot = Some(KeyIndex::of(list));
}
slot.get_or_insert_with(|| KeyIndex::of(list))
}
}
const STYLES: List<Style> = List {
whole: DocProp::Styles,
order: DocProp::StyleOrder,
entry: Entry::Style,
get: |d| &d.styles,
get_mut: |d| &mut d.styles,
index: |i| &mut i.styles,
};
const COMMENTS: List<Comment> = List {
whole: DocProp::Comments,
order: DocProp::CommentOrder,
entry: Entry::Comment,
get: |d| &d.comments,
get_mut: |d| &mut d.comments,
index: |i| &mut i.comments,
};
const COLLECTIONS: List<Collection> = List {
whole: DocProp::Collections,
order: DocProp::CollectionOrder,
entry: Entry::Collection,
get: |d| &d.variables.collections,
get_mut: |d| &mut d.variables.collections,
index: |i| &mut i.collections,
};
const VARIABLES: List<Variable> = List {
whole: DocProp::Variables,
@@ -73,6 +126,7 @@ const VARIABLES: List<Variable> = List {
entry: Entry::Variable,
get: |d| &d.variables.variables,
get_mut: |d| &mut d.variables.variables,
index: |i| &mut i.variables,
};
impl<T: Keyed> List<T> {
@@ -111,25 +165,53 @@ impl<T: Keyed> List<T> {
Ok(())
}
fn put(&self, doc: &mut Document, key: T::Key, value: &Json) -> Result<(), Error> {
fn put(&self, doc: &mut Document, key: T::Key, value: &Json, index: &mut Index) -> Result<(), Error> {
let t: T = value.read("entry")?;
if t.key() != key {
return Err(Error::Invalid("an entry's id must match the entry it is put at".into()));
}
let list = (self.get_mut)(doc);
match list.iter_mut().find(|e| e.key() == key) {
let ix = KeyIndex::current((self.index)(index), list, &key);
match ix.at.get(&key).copied().and_then(|i| list.get_mut(i)) {
Some(e) => *e = t,
None => list.push(t),
None => {
list.push(t);
ix.at.insert(key, list.len() - 1);
ix.len = list.len();
}
}
Ok(())
}
fn remove(&self, doc: &mut Document, key: &T::Key) {
(self.get_mut)(doc).retain(|e| e.key() != *key);
fn remove(&self, doc: &mut Document, key: &T::Key, index: &mut Index) {
let list = (self.get_mut)(doc);
let slot = (self.index)(index);
let ix = KeyIndex::current(slot, list, key);
// An absent entry leaves the list as it is, without a scan of it.
let Some(at) = ix.at.get(key).copied() else {
return;
};
let before = list.len();
list.retain(|e| e.key() != *key);
// One entry gone at `at`: the index is kept current by shifting the entries after it down,
// so the next removal in the batch need not index the list again. Duplicates of the key
// (more than one entry gone) leave it to be rebuilt.
if list.len() + 1 == before {
ix.at.remove(key);
for i in ix.at.values_mut() {
if *i > at {
*i -= 1;
}
}
ix.len = list.len();
} else {
*slot = None;
}
}
/// Entries in `keys` order first, the rest after in their current order.
fn reorder(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
fn reorder(&self, doc: &mut Document, value: &Json, index: &mut Index) -> Result<(), Error> {
*(self.index)(index) = None;
let keys: Vec<T::Key> = value.read("order")?;
let list = (self.get_mut)(doc);
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
@@ -150,7 +232,8 @@ impl<T: Keyed> List<T> {
Ok(())
}
fn set_whole(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
fn set_whole(&self, doc: &mut Document, value: &Json, index: &mut Index) -> Result<(), Error> {
*(self.index)(index) = None;
*(self.get_mut)(doc) = value.read("list")?;
Ok(())
}
@@ -313,31 +396,38 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
}
/// Apply a document-level change. Returns whether it can change which pages are listed (the
/// library store page is never listed).
/// [`apply_indexed`] for one change on its own.
#[cfg(test)]
pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error> {
apply_indexed(doc, change, &mut Index::default())
}
/// Apply a document-level change, with the keyed lists' positions kept in `index` between the
/// changes of one batch. Returns whether it can change which pages are listed (the library store
/// page is never listed).
pub(crate) fn apply_indexed(doc: &mut Document, change: &Change, index: &mut Index) -> Result<bool, Error> {
match change {
Change::SetDoc { prop, value } => {
if set_scalar(doc, *prop, value)? {
return Ok(*prop == DocProp::Libraries);
}
match prop {
DocProp::Styles => STYLES.set_whole(doc, value),
DocProp::StyleOrder => STYLES.reorder(doc, value),
DocProp::Comments => COMMENTS.set_whole(doc, value),
DocProp::CommentOrder => COMMENTS.reorder(doc, value),
DocProp::Collections => COLLECTIONS.set_whole(doc, value),
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value),
DocProp::Variables => VARIABLES.set_whole(doc, value),
DocProp::VariableOrder => VARIABLES.reorder(doc, value),
DocProp::Styles => STYLES.set_whole(doc, value, index),
DocProp::StyleOrder => STYLES.reorder(doc, value, index),
DocProp::Comments => COMMENTS.set_whole(doc, value, index),
DocProp::CommentOrder => COMMENTS.reorder(doc, value, index),
DocProp::Collections => COLLECTIONS.set_whole(doc, value, index),
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value, index),
DocProp::Variables => VARIABLES.set_whole(doc, value, index),
DocProp::VariableOrder => VARIABLES.reorder(doc, value, index),
_ => Err(Error::Invalid(format!("{prop:?} is not a list"))),
}?;
}
Change::Put { entry, value } => match entry {
Entry::Style(k) => STYLES.put(doc, k.clone(), value)?,
Entry::Comment(k) => COMMENTS.put(doc, *k, value)?,
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value)?,
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value)?,
Entry::Style(k) => STYLES.put(doc, k.clone(), value, index)?,
Entry::Comment(k) => COMMENTS.put(doc, *k, value, index)?,
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value, index)?,
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value, index)?,
Entry::DevSnapshot(k) => {
let snap: DevSnapshot = value.read("dev snapshot")?;
doc.dev_snapshots.insert(*k, Arc::new(snap));
@@ -351,14 +441,18 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
}
},
Change::Remove { entry } => match entry {
Entry::Style(k) => STYLES.remove(doc, k),
Entry::Comment(k) => COMMENTS.remove(doc, k),
Entry::Collection(k) => COLLECTIONS.remove(doc, k),
Entry::Variable(k) => VARIABLES.remove(doc, k),
Entry::Style(k) => STYLES.remove(doc, k, index),
Entry::Comment(k) => COMMENTS.remove(doc, k, index),
Entry::Collection(k) => COLLECTIONS.remove(doc, k, index),
Entry::Variable(k) => VARIABLES.remove(doc, k, index),
Entry::DevSnapshot(k) => {
doc.dev_snapshots.remove(k);
}
Entry::Image(k) => {
// Nothing to remove: no need to look through the document for what shows it.
if !doc.images.contains_key(k) {
return Ok(false);
}
// An image a layer or style shows stays: removing it would let other bytes go
// under its key, swapping the picture that layer shows ([`image_for`]).
if figcraft_doc::used_images(doc).contains(k) {
+132
View File
@@ -0,0 +1,132 @@
//! Keyed document lists (comments, styles, variables, collections) applied in large batches: a
//! batch of new entries is placed through an index, not a scan of the growing list per entry, and
//! puts, removes and reorders in one batch still leave the list a scan would.
use figcraft_doc::{Comment, Document, Message};
use crate::change::{Change, DocProp, Entry, Json};
use crate::{Mode, Replica};
fn comment(id: u64, text: &str, doc: &Document) -> Comment {
let page = doc.first_page().unwrap();
Comment { id, page, x: 1.0, y: 2.0, anchor: None, first: Message::new("A", text, 0), resolved: false, replies: Vec::new(), read_by: None }
}
fn put(c: &Comment) -> Change {
Change::Put { entry: Entry::Comment(c.id), value: Json::of(c).unwrap() }
}
#[test]
fn a_large_batch_of_new_comments_lands_whole_and_in_order() {
let doc = Document::new("t");
let changes: Vec<Change> = (1..=100_000).map(|i| put(&comment(i, "hi", &doc))).collect();
let mut r = Replica::new(doc);
// A scan per put took minutes here; indexed it takes well under a second, even unoptimised.
r.apply(&changes, Mode::Strict).unwrap();
assert_eq!(r.document().comments.len(), 100_000);
assert!(r.document().comments.iter().zip(1..).all(|(c, i)| c.id == i));
}
#[test]
fn puts_removes_and_reorders_in_one_batch_match_one_by_one() {
let doc = Document::new("t");
let c = |i, t| comment(i, t, &doc);
let batch = vec![
put(&c(1, "a")),
put(&c(2, "b")),
put(&c(3, "c")),
// Replaced in place.
put(&c(2, "B")),
Change::Remove { entry: Entry::Comment(1) },
// Absent: nothing changes.
Change::Remove { entry: Entry::Comment(9) },
put(&c(4, "d")),
put(&c(6, "f")),
// Two removals in a row: the second goes through the index the first kept current.
Change::Remove { entry: Entry::Comment(2) },
Change::Remove { entry: Entry::Comment(6) },
put(&c(2, "B")),
put(&c(3, "C")),
Change::SetDoc { prop: DocProp::CommentOrder, value: Json::of(&[4u64, 3, 2]).unwrap() },
put(&c(2, "BB")),
put(&c(1, "again")),
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&vec![c(7, "x"), c(8, "y")]).unwrap() },
put(&c(8, "Y")),
put(&c(5, "e")),
];
let mut together = Replica::new(doc.clone());
together.apply(&batch, Mode::Strict).unwrap();
let mut one_by_one = Replica::new(doc.clone());
for ch in &batch {
one_by_one.apply(std::slice::from_ref(ch), Mode::Strict).unwrap();
}
assert_eq!(together.document().comments, one_by_one.document().comments);
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
assert_eq!(got, vec![(7, "x"), (8, "Y"), (5, "e")]);
}
#[test]
fn removing_an_absent_image_does_not_fail() {
let mut doc = Document::new("t");
let remove = Change::Remove { entry: Entry::Image("missing".into()) };
assert!(!crate::docprops::apply(&mut doc, &remove).unwrap());
}
#[test]
fn removals_in_one_batch_keep_the_index_current() {
let doc = Document::new("t");
let c = |i, t| comment(i, t, &doc);
let batch = vec![
put(&c(1, "a")),
put(&c(2, "b")),
put(&c(3, "c")),
put(&c(4, "d")),
put(&c(5, "e")),
Change::Remove { entry: Entry::Comment(2) },
Change::Remove { entry: Entry::Comment(9) },
Change::Remove { entry: Entry::Comment(4) },
// Replaced where the shifted index says they now are.
put(&c(5, "E")),
put(&c(3, "C")),
put(&c(1, "A")),
Change::Remove { entry: Entry::Comment(1) },
put(&c(2, "B")),
put(&c(5, "EE")),
];
let mut together = Replica::new(doc.clone());
together.apply(&batch, Mode::Strict).unwrap();
let mut one_by_one = Replica::new(doc.clone());
for ch in &batch {
one_by_one.apply(std::slice::from_ref(ch), Mode::Strict).unwrap();
}
assert_eq!(together.document().comments, one_by_one.document().comments);
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
assert_eq!(got, vec![(3, "C"), (5, "EE"), (2, "B")]);
}
#[test]
fn duplicate_keys_from_a_whole_list_match_one_by_one() {
let doc = Document::new("t");
let c = |i, t| comment(i, t, &doc);
let batch = vec![
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&vec![c(1, "a"), c(2, "b"), c(2, "b2"), c(3, "c"), c(4, "d")]).unwrap() },
// The first entry with the key is the one replaced, as a scan finds it.
put(&c(2, "B")),
put(&c(4, "D")),
// Both entries with the key go: the index is built again for what follows.
Change::Remove { entry: Entry::Comment(2) },
put(&c(3, "C")),
Change::Remove { entry: Entry::Comment(1) },
put(&c(2, "again")),
put(&c(4, "DD")),
];
let mut together = Replica::new(doc.clone());
together.apply(&batch, Mode::Lenient).unwrap();
let mut one_by_one = Replica::new(doc.clone());
for ch in &batch {
one_by_one.apply(std::slice::from_ref(ch), Mode::Lenient).unwrap();
}
assert_eq!(together.document().comments, one_by_one.document().comments);
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
assert_eq!(got, vec![(3, "C"), (4, "DD"), (2, "again")]);
}
+2
View File
@@ -148,6 +148,8 @@ mod image_tests;
#[cfg(test)]
mod journal_tests;
#[cfg(test)]
mod keyed_list_tests;
#[cfg(test)]
mod merge_list_tests;
#[cfg(test)]
mod merge_tests;
+7 -4
View File
@@ -192,8 +192,9 @@ impl Replica {
self.doc.take_dirty();
let mut out = Applied::default();
let (mut relist, mut relist_all) = (BTreeSet::new(), false);
let mut index = docprops::Index::default();
for c in changes {
match self.apply_one(c, &mut out) {
match self.apply_one(c, &mut out, &mut index) {
Ok(Relist::No) => {}
Ok(Relist::Page(p)) => {
relist.insert(p);
@@ -214,7 +215,7 @@ impl Replica {
}
/// Apply one change. Returns what of the page list needs rebuilding.
fn apply_one(&mut self, c: &Change, out: &mut Applied) -> Result<Relist, Error> {
fn apply_one(&mut self, c: &Change, out: &mut Applied, index: &mut docprops::Index) -> Result<Relist, Error> {
match c {
Change::Create { id, kind, parent, position, props } => {
frac::validate(position).map_err(|e| Error::Invalid(e.to_string()))?;
@@ -235,7 +236,9 @@ impl Replica {
self.doc.insert_page(node).map_err(doc_err)?;
}
(false, Some(p)) => {
let at = self.slot(&self.doc.get(*p).map_err(doc_err)?.children, *id, position);
// A new id sits in no sibling list yet: no scan for it, just the binary search.
let siblings = &self.doc.get(*p).map_err(doc_err)?.children;
let at = siblings.partition_point(|c| (self.key(*c), *c) < (position.as_str(), *id));
self.doc.insert(node, *p, Some(at)).map_err(doc_err)?;
}
_ => return Err(Error::Invalid("pages, and only pages, have no parent".into())),
@@ -297,7 +300,7 @@ impl Replica {
Ok(if page { Relist::Page(*id) } else { Relist::No })
}
Change::SetDoc { .. } | Change::Put { .. } | Change::Remove { .. } => {
Ok(if docprops::apply(&mut self.doc, c)? { Relist::All } else { Relist::No })
Ok(if docprops::apply_indexed(&mut self.doc, c, index)? { Relist::All } else { Relist::No })
}
}
}
+18 -6
View File
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque};
use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use crate::change::{Change, Entry, Prop, Target};
use crate::change::{Change, Entry, Family, Prop, Target};
use crate::message::{
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
};
@@ -413,10 +413,14 @@ impl Server {
// client learned what happened to it from the catch-up.
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
ClientMsg::Changes { base_seq, batch_id, changes } => {
let mut denied = true;
let result = match self.limited.get(&from) {
Some(Access::View) => Err(Error::Invalid(VIEW_ONLY.into())),
Some(Access::Comment) if !changes.iter().all(Change::is_comment) => Err(Error::Invalid(COMMENT_ONLY.into())),
_ => self.accept(from, base_seq, &changes),
_ => {
denied = false;
self.accept(from, base_seq, &changes)
}
};
if let Some(p) = self.issued.get_mut(&from) {
p.note(batch_id, result.is_err());
@@ -430,7 +434,9 @@ impl Server {
}
Err(e) => {
self.note(Journal::Refused { client: from, batch_id });
let fixes = self.fixes(&changes).unwrap_or_default();
// A refusal for lack of permission costs no repair: the client's confirmed copy
// already is the server's state, so the fixes would only repeat it.
let fixes = if denied { Vec::new() } else { self.fixes(&changes).unwrap_or_default() };
vec![Outgoing::To(from, ServerMsg::Reject { batch_id, reason: reason(&e), fixes })]
}
}
@@ -557,6 +563,7 @@ impl Server {
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
let before = self.state.document();
let mut touched: BTreeSet<Entry> = BTreeSet::new();
let mut listed: BTreeMap<Family, (BTreeSet<Entry>, BTreeSet<Entry>)> = BTreeMap::new();
for c in changes {
match c {
Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => {
@@ -565,8 +572,10 @@ impl Server {
// A whole list replaced (the fallback for lists whose ids aren't unique).
Change::SetDoc { prop, .. } => {
if let Some(Ok(family)) = prop.list() {
touched.extend(docprops::entries(before, family));
touched.extend(docprops::entries(after, family));
let (b, a) = (docprops::entries(before, family), docprops::entries(after, family));
touched.extend(b.iter().chain(&a).cloned());
// Looked up in sets below: a linear search per entry would be quadratic.
listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect()));
}
}
_ => {}
@@ -574,7 +583,10 @@ impl Server {
}
let mut out = Vec::new();
for entry in touched {
let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry));
let (was, now) = match entry.family().and_then(|f| listed.get(&f)) {
Some((b, a)) => (b.contains(&entry), a.contains(&entry)),
None => (docprops::has(before, &entry), docprops::has(after, &entry)),
};
if was == now {
continue;
}
+20
View File
@@ -182,6 +182,26 @@ fn deleted_nodes_can_come_back_but_ids_are_never_handed_out_twice() {
assert_eq!(s.document(), gone.document());
}
#[test]
fn a_viewers_refused_deletion_gets_no_repair() {
let (d, _, frame) = doc();
let mut s = Server::new(d, "tok", Limits::default());
let (id, out) = s.join_as(hello("tok"), crate::message::Access::View).unwrap();
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!() };
let mut v = Client::join(welcome).unwrap();
let del = edit(&mut v, |d| d.remove(frame).unwrap());
let (reason, fixes) = rejected(
&mut s,
id,
match del {
ClientMsg::Changes { changes, .. } => changes,
_ => panic!(),
},
);
assert_eq!(reason, crate::VIEW_ONLY);
assert!(fixes.is_empty(), "{fixes:?}");
}
#[test]
fn fixes_roll_a_rejected_client_back_to_the_server_state() {
let (d, _, frame) = doc();
+24 -18
View File
@@ -376,26 +376,32 @@ impl Gradient {
s
}
/// The colour at `t` (0..1) along the gradient.
/// The colour at `t` (0..1) along the gradient. Sorts the stops on every call: to sample one
/// gradient many times, sort once with [`Self::sorted_stops`] and use [`sample_sorted`].
pub fn sample(&self, t: f32) -> Color {
let stops = self.sorted_stops();
let t = clamp01(t);
let mut prev = match stops.first() {
Some(s) => *s,
None => return Color::BLACK,
};
if t <= prev.position {
return prev.color;
sample_sorted(&self.sorted_stops(), t)
}
}
/// The colour at `t` (0..1) along stops already sorted by [`Gradient::sorted_stops`]: found by a
/// binary search, so a renderer sampling a file's gradient per pixel doesn't scan or sort its
/// stops each time.
pub fn sample_sorted(stops: &[ColorStop], t: f32) -> Color {
let t = clamp01(t);
let Some(first) = stops.first() else { return Color::BLACK };
if t <= first.position {
return first.color;
}
// The first stop after the first one at or past `t`, and the stop before it.
let rest = stops.get(1..).unwrap_or_default();
let i = rest.partition_point(|s| s.position < t);
match (rest.get(i), stops.get(i)) {
(Some(s), Some(prev)) => {
let span = s.position - prev.position;
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
prev.color.lerp(s.color, f)
}
for s in stops.iter().skip(1) {
if t <= s.position {
let span = s.position - prev.position;
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
return prev.color.lerp(s.color, f);
}
prev = *s;
}
prev.color
_ => stops.last().map_or(Color::BLACK, |s| s.color),
}
}
+38
View File
@@ -59,6 +59,44 @@ fn gradient_sampling() {
assert_eq!(g.sample(0.5).to_hex(), "808080");
}
#[test]
fn sampling_sorted_stops_matches_a_scan() {
// The scan `sample` used to make: the first stop at or past `t`, mixed with the one before.
fn scan(stops: &[ColorStop], t: f32) -> Color {
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
let mut prev = stops[0];
if t <= prev.position {
return prev.color;
}
for s in &stops[1..] {
if t <= s.position {
let span = s.position - prev.position;
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
return prev.color.lerp(s.color, f);
}
prev = *s;
}
prev.color
}
let grey = |v: f32| Color { r: v, g: v, b: v, a: 1.0 };
let positions = [0.3, 0.0, 0.3, 1.0, 0.7, f32::NAN, 0.5, 0.5, 0.9];
let g = Gradient {
kind: GradientKind::Diamond,
stops: positions.iter().enumerate().map(|(i, p)| ColorStop { position: *p, color: grey(i as f32 / 10.0) }).collect(),
from: (0.5, 0.5),
to: (1.0, 0.5),
width: (0.5, 1.0),
};
let sorted = g.sorted_stops();
for i in -10..=110 {
let t = i as f32 / 100.0;
assert_eq!(sample_sorted(&sorted, t), scan(&sorted, t), "t = {t}");
assert_eq!(g.sample(t), scan(&sorted, t), "t = {t}");
}
assert_eq!(sample_sorted(&sorted, f32::NAN), scan(&sorted, f32::NAN));
assert_eq!(sample_sorted(&[], 0.5), Color::BLACK);
}
#[test]
fn paint_serde_shape() {
let p = Paint::solid(Color::from_hex("FF0000").unwrap());
+19 -2
View File
@@ -574,6 +574,19 @@ impl Document {
Ok(())
}
/// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing
/// them one by one would search that list once per child).
pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> {
let kids = std::mem::take(&mut self.get_mut(id)?.children);
for c in kids {
for d in self.subtree(c) {
self.nodes.remove(&d);
self.dirty.touch(d);
}
}
Ok(())
}
/// Whether sections may go in `parent`: a page or another section (never a frame or a group).
pub fn takes_sections(&self, parent: NodeId) -> bool {
self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
@@ -1085,6 +1098,9 @@ impl Document {
// lookups: a wide parent or a long page list doesn't make validation quadratic.
let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect();
let page_ids: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
if page_ids.len() != self.pages.len() {
note(&mut issues, || "a page is listed twice".to_string());
}
for (id, n) in self.nodes.iter() {
if n.id != *id {
note(&mut issues, || format!("node key {id} has id {}", n.id));
@@ -1178,9 +1194,10 @@ impl Document {
if store != self.libraries.store {
Arc::make_mut(&mut self.libraries).store = store;
}
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
// A page listed twice would be imported, exported and walked once per listing.
let mut listed = std::collections::BTreeSet::new();
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store && listed.insert(*p));
// Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
let mut listed: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
for (k, n) in self.nodes.iter() {
if n.is_page() && Some(*k) != store && listed.insert(*k) {
self.pages.push(*k);
+25 -3
View File
@@ -12,6 +12,8 @@ use crate::{CollectionId, ModeId, VariableValue, Variables};
pub const MAX_EXPR_DEPTH: usize = 32;
/// Longest string an expression builds (characters).
const MAX_STRING: usize = 10_000;
/// The most string bytes one operation's operands may hold together (generous: results cap at `MAX_STRING`).
const MAX_OPERAND_BYTES: usize = 1 << 20;
/// An expression over variables.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -115,7 +117,19 @@ impl Expr {
}
Expr::Op { op, args } => (*op, args),
};
let vals: Vec<VariableValue> = args.iter().map(|a| a.eval_at(vars, modes, depth + 1)).collect::<Option<_>>()?;
// Retained operands are bounded by bytes, so repeated references to one long string can't multiply it.
let mut text_bytes = 0usize;
let mut vals = Vec::with_capacity(args.len());
for a in args {
let v = a.eval_at(vars, modes, depth + 1)?;
if let VariableValue::String(s) = &v {
text_bytes = text_bytes.saturating_add(s.len());
if text_bytes > MAX_OPERAND_BYTES {
return None;
}
}
vals.push(v);
}
apply(op, &vals)
}
@@ -259,10 +273,18 @@ fn apply(op: ExprOp, vals: &[VariableValue]) -> Option<VariableValue> {
match op {
Add if vals.iter().any(|v| matches!(v, VariableValue::String(_))) => {
let mut s = String::new();
let mut chars = 0;
for v in vals {
s.push_str(&as_text(v)?);
// Every operand is checked (a colour makes the whole expression None); only the
// characters it adds are counted, and appending stops at the cap.
let t = as_text(v)?;
if chars < MAX_STRING {
let before = s.len();
s.extend(t.chars().take(MAX_STRING - chars));
chars += s[before..].chars().count();
}
}
Some(VariableValue::String(s.chars().take(MAX_STRING).collect()))
Some(VariableValue::String(s))
}
Add => fold(|a, b| Some(a + b)),
Subtract => fold(|a, b| Some(a - b)),
+16
View File
@@ -124,6 +124,13 @@ fn deep_and_hostile_expressions_are_bounded() {
let joined = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
let Some(VariableValue::String(t)) = eval(&joined) else { panic!() };
assert_eq!(t.chars().count(), 10_000, "capped");
// Past the cap a colour operand still fails the expression, and empty operands add nothing.
let color = Expr::Value(VariableValue::Color(Color::BLACK));
let mut ops = vec![s(&"x".repeat(10_000))];
ops.extend((0..50).map(|_| s("")));
assert!(matches!(eval(&Expr::op(ExprOp::Add, ops.clone())), Some(VariableValue::String(t)) if t.chars().count() == 10_000));
ops.push(color);
assert_eq!(eval(&Expr::op(ExprOp::Add, ops)), None, "a colour past the cap is still a type error");
}
fn nav(id: u64) -> Action {
@@ -199,3 +206,12 @@ fn serde_round_trip_and_old_files() {
let old: Action = serde_json::from_str(r#"{"type":"setVariable","variable":"n","value":2}"#).unwrap_or(Action::Back);
assert_eq!(old, Action::SetVariable { variable: "n".into(), value: serde_json::json!(2), expr: None });
}
#[test]
fn operands_holding_too_many_string_bytes_are_refused() {
let long = "x".repeat(300_000);
let few = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
assert!(eval(&few).is_some());
let many = Expr::op(ExprOp::Add, (0..8).map(|_| s(&long)).collect());
assert_eq!(eval(&many), None);
}
+28
View File
@@ -348,6 +348,20 @@ fn repair_drops_cycles_and_repeated_children() {
assert_eq!(bad.subtree(p), vec![p, r]);
}
#[test]
fn repair_lists_a_page_once_however_often_the_file_lists_it() {
let mut d = Document::new("x");
let p = page(&d);
let q = d.add_page("Two");
let mut v = serde_json::to_value(&d).unwrap();
v["pages"] = serde_json::json!([p, q, p, q, p]);
let mut bad: Document = serde_json::from_value(v).unwrap();
assert!(bad.validate().iter().any(|i| i.contains("listed twice")), "{:?}", bad.validate());
bad.repair();
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
assert_eq!(bad.pages, vec![p, q]);
}
#[test]
fn validate_caps_the_issue_list() {
let mut d = Document::new("x");
@@ -846,3 +860,17 @@ fn the_colour_walker_visits_calculation_literals() {
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
}
#[test]
fn remove_children_removes_every_subtree_and_keeps_the_parent() {
let mut doc = Document::new("T");
let page = doc.first_page().unwrap();
let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap();
let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap();
let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap();
doc.remove_children(frame).unwrap();
assert!(doc.contains(frame) && doc.contains(other));
assert!(!doc.contains(inner) && !doc.contains(leaf));
assert!(doc.get(frame).unwrap().children.is_empty());
}
+20
View File
@@ -234,6 +234,22 @@ pub enum LeadingTrim {
CapHeight,
}
/// Where lines break when text wraps to more than one line ("Wrap style controls where lines
/// break when text wraps to more than one line", Explore text properties).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum WrapStyle {
/// "Figma fits as many words as possible on each line before wrapping to the next."
#[default]
Auto,
/// "Figma distributes text as evenly as possible across every line, so each line is close to
/// the same length."
Balance,
/// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single
/// word stranded alone on the last line."
Pretty,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ListStyle {
@@ -466,6 +482,10 @@ pub struct TextData {
/// as fit).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub max_lines: u32,
/// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow
/// (Balance), or full with no one word alone on the last line (Pretty).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub wrap_style: WrapStyle,
/// OpenType feature toggles (e.g. "liga": false).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub features: std::collections::BTreeMap<String, bool>,
+4
View File
@@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
/// main layer's, for files from before `modes` was an override group. Copies follow their main
/// layer's modes, so a layer on other modes chose them itself.
pub fn mark_layer_modes(doc: &mut Document) {
// Without an instance nothing can be inside one: skip the per-layer ancestry lookups.
if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) {
return;
}
let chose: Vec<NodeId> = doc
.nodes()
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
@@ -132,6 +132,7 @@ fn text_style_differs(x: &TextData, y: &TextData) -> bool {
|| x.hanging_list != y.hanging_list
|| x.max_lines != y.max_lines
|| x.features != y.features
|| x.wrap_style != y.wrap_style
}
/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was
@@ -160,6 +160,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
b.hanging_list = a.hanging_list;
b.max_lines = a.max_lines;
b.features = a.features.clone();
b.wrap_style = a.wrap_style;
}
to.styles.text = from.styles.text.clone();
}
+9 -1
View File
@@ -277,8 +277,16 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<VariableId> {
let mut out = BTreeSet::new();
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
if stack.is_empty() {
return out;
}
// Indexed once (the first of equal ids, as `get_by_id` finds it): a scan per step is quadratic on a long alias chain.
let mut by_id = std::collections::HashMap::new();
for v in &vars.variables {
by_id.entry(v.id.as_str()).or_insert(v);
}
while let Some(cur) = stack.pop() {
let Some(v) = vars.get_by_id(&cur) else { continue };
let Some(v) = by_id.get(cur.as_str()).copied() else { continue };
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
continue;
}
+4 -1
View File
@@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage {
let records: std::collections::BTreeMap<NodeId, NodeId> =
doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect();
if !records.is_empty() {
// One component's asset root is looked up once, however many instances use it.
let mut roots = std::collections::BTreeMap::<NodeId, Option<NodeId>>::new();
for n in doc.nodes() {
if let NodeKind::Instance { component, .. } = &n.kind
&& store.is_none_or(|st| doc.page_of(n.id) != Some(st))
&& let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r))
&& let Some(key) =
roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r))
{
let c = counts.entry(*key).or_default();
*c = c.saturating_add(1);
+8 -2
View File
@@ -156,14 +156,20 @@ impl BatchScope {
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
}
/// Most step names a missing-reference error lists.
const NAME_HINTS: usize = 12;
fn known_names(&self) -> String {
let mut names: Vec<_> = self.names.iter().collect();
names.sort_by_key(|(_, i)| **i);
if names.is_empty() {
return "no step has an `as` name".into();
}
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
list.join(", ")
// A few of them: the list is built for every missing reference and, with `onError:
// continue`, kept for each, so a long table would cost its length per failed step.
let list: Vec<_> = names.iter().take(Self::NAME_HINTS).map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
let more = names.len().saturating_sub(Self::NAME_HINTS);
if more > 0 { format!("{}, {more} more", list.join(", ")) } else { list.join(", ") }
}
}
+4
View File
@@ -103,6 +103,7 @@ pub const NODE_KEYS: &[&str] = &[
"visible",
"width",
"widthProfile",
"wrapStyle",
"x",
"y",
];
@@ -442,6 +443,9 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
if t.leading_trim != figcraft_doc::LeadingTrim::None {
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
}
if t.wrap_style != figcraft_doc::WrapStyle::Auto {
insert_prop(o, options, "wrapStyle", || json!(t.wrap_style));
}
if !t.runs.is_empty() {
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
}
+3 -1
View File
@@ -109,7 +109,9 @@ pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out.push(id);
}
}
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i)).collect();
// Each id counts once: a repeated group must not be walked once per repeat.
let mut seen = std::collections::BTreeSet::new();
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i) && seen.insert(*i)).collect();
let mut out = Vec::new();
for id in roots(doc, &ids) {
walk(doc, id, 0, &mut out);
+28 -12
View File
@@ -403,7 +403,9 @@ fn visible_handles(net: &VectorNetwork, ve: &VectorEdit) -> Vec<(usize, bool)> {
}
/// Hit test in page coordinates with a tolerance in page units. Visible handles win, then
/// vertices, then segments.
/// vertices, then segments; between a handle and a point both within reach, the nearer one on
/// the page, the handle on a tie (the handles of a short segment end a few pixels from their
/// point, and a click on the point is on the point, #1875).
pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<VHit> {
let net = network(doc, ve.id)?;
let w = doc.world_transform(ve.id);
@@ -414,17 +416,31 @@ pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<VHit>
if !(lp.x.is_finite() && lp.y.is_finite()) {
return None;
}
for (si, start) in visible_handles(net, ve) {
let (Some(seg), Some(t)) = (net.segments.get(si), net.handle(si, start)) else { continue };
let Some(v) = net.vertices.get(if start { seg.a } else { seg.b }) else { continue };
if (Point::new(v.x + t.0, v.y + t.1) - lp).hypot() <= tl {
return Some(VHit::Handle { seg: si, start });
}
}
let nearest_vertex =
net.vertices.iter().enumerate().map(|(i, v)| (i, (v.point() - lp).hypot())).filter(|(_, d)| *d <= tl).min_by(|a, b| a.1.total_cmp(&b.1));
if let Some((index, _)) = nearest_vertex {
return Some(VHit::Vertex { index });
// Candidates are within the tolerance as before; the nearest is judged on the page, where a
// non-uniform scale doesn't distort it.
let on_page = |q: Point| (w * q - p).hypot();
let nearest_handle = visible_handles(net, ve)
.into_iter()
.filter_map(|(si, start)| {
let (seg, t) = (net.segments.get(si)?, net.handle(si, start)?);
let v = net.vertices.get(if start { seg.a } else { seg.b })?;
let end = Point::new(v.x + t.0, v.y + t.1);
((end - lp).hypot() <= tl).then(|| ((si, start), on_page(end)))
})
.min_by(|a, b| a.1.total_cmp(&b.1));
let nearest_vertex = net
.vertices
.iter()
.enumerate()
.filter(|(_, v)| (v.point() - lp).hypot() <= tl)
.map(|(i, v)| (i, on_page(v.point())))
.min_by(|a, b| a.1.total_cmp(&b.1));
match (nearest_handle, nearest_vertex) {
// A tie (a handle's end on another point) goes to the handle, as handles always won before.
(Some(((seg, start), dh)), Some((_, dv))) if dh <= dv => return Some(VHit::Handle { seg, start }),
(Some(((seg, start), _)), None) => return Some(VHit::Handle { seg, start }),
(_, Some((index, _))) => return Some(VHit::Vertex { index }),
(None, None) => {}
}
use figcraft_geom::kurbo::ParamCurveNearest;
let nearest_seg = (0..net.segments.len())
@@ -1072,12 +1072,18 @@ impl Session {
}
}
VGesture::BendVertex { v, moved, smooth, .. } => {
let id = ve.id;
if moved {
self.commit_interaction()?;
} else if smooth {
let id = ve.id;
self.preview(|doc, _| edit(doc, id, |net| ops::make_sharp(net, v)))?;
self.commit_interaction()?;
} else if network(self.doc()?, id).is_some_and(|n| n.incident(v).len() == 2) {
// "Click on a point or path where you want to add a curve" (Edit vector
// layers): a sharp point between two segments gets handles along the line
// between its neighbours (#1875); an endpoint has nothing to curve.
self.preview(|doc, _| edit(doc, id, |net| ops::auto_smooth(net, v)))?;
self.commit_interaction()?;
} else {
self.cancel_interaction()?;
}
@@ -119,6 +119,16 @@ fn bend_tool_smooths_and_sharpens_points() {
let n = net(&s, id);
let v = vertex_at(&n, 100.0, 0.0);
assert!(n.incident(v).iter().all(|(s, st)| n.handle(*s, *st) == Some((0.0, 0.0))), "a click sharpens it");
// Clicked again, the sharp corner curves: handles along the line between its neighbours
// (0,0) and (100,100), a third of the way to each (the box refits around the bulge, so the
// point is found by its index).
click(&mut s, 100.0, 0.0);
let n = net(&s, id);
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
for (seg, st) in n.incident(v) {
let h = n.handle(seg, st).unwrap();
assert!((h.0.abs() - 100.0 / 3.0 / 2f64.sqrt()).abs() < 1e-6 && (h.0 - h.1).abs() < 1e-6, "along the neighbours' line: {h:?}");
}
// ⌘-drag on a segment with the Move tool bends it.
ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
let mut down = ev(PointerKind::Down, 0.0, 50.0);
@@ -130,6 +140,123 @@ fn bend_tool_smooths_and_sharpens_points() {
assert!(b.x0 < -25.0, "bent outwards: {b:?}");
}
/// #1875: a line whose middle point was moved off the line, clicked with the Bend tool, curves
/// there ("Click on a point or path where you want to add a curve", Edit vector layers): the
/// handles run along the line between the neighbours, a third of the way to each, in one undo
/// step; an endpoint is left alone; a click on the curved point makes it sharp again.
#[test]
fn a_bend_click_curves_a_sharp_point_between_its_neighbours() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 30], [100, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.tool", json!({"tool": "bend"}));
let (v, end) = (vertex_at(&net(&s, id), 50.0, 30.0), vertex_at(&net(&s, id), 0.0, 0.0));
let before = s.undo_depth().0;
click(&mut s, 50.0, 30.0);
let n = net(&s, id);
let reach = (50f64.powi(2) + 30f64.powi(2)).sqrt() / 3.0;
for (seg, st) in n.incident(v) {
let h = n.handle(seg, st).unwrap();
assert!((h.0.abs() - reach).abs() < 1e-6 && h.1.abs() < 1e-6, "along the neighbours' line, a third of the way: {h:?}");
}
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
assert_eq!(s.undo_depth().0, before + 1, "one undo step");
// An endpoint has nothing to curve: the click changes nothing and leaves no undo step.
click(&mut s, 0.0, 0.0);
let n = net(&s, id);
assert!(n.incident(end).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))));
assert_eq!(s.undo_depth().0, before + 1);
// The curved point clicked again is sharp again.
click(&mut s, 50.0, 30.0);
let n = net(&s, id);
assert!(n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))));
}
/// Between a handle's end and a point both within reach, the nearer on the page wins, also under a
/// non-uniform scale (10× wide here), where local distances would pick the point.
#[test]
fn a_handle_and_a_point_are_compared_on_the_page() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [5, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
{
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
let mut n = VectorNetwork::default();
n.vertices.push(figcraft_doc::Vertex::new(0.0, 0.0));
n.vertices.push(figcraft_doc::Vertex::new(5.0, 0.0));
let mut seg = figcraft_doc::Segment::line(0, 1);
seg.ta = (0.5, -0.9);
n.segments.push(seg);
node.kind = figcraft_doc::NodeKind::Vector { network: n };
node.transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0);
}
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
let doc = s.doc().unwrap().clone();
// On the page the point is at (0, 0) and its handle's end at (5, -0.9): from (4, 0) the
// handle's end is 1.3 away and the point 4, though locally the point is the nearer.
assert_eq!(hit(&doc, &ve(&s), pt(4.0, 0.0), 6.0), Some(VHit::Handle { seg: 0, start: true }));
assert_eq!(hit(&doc, &ve(&s), pt(0.0, 0.0), 6.0), Some(VHit::Vertex { index: 0 }));
}
/// A handle whose end lies exactly on another point (snapped there) can still be taken there: on a
/// tie the handle wins, as handles always did.
#[test]
fn a_handle_on_another_point_is_still_the_handles() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [10, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
{
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
let mut n = VectorNetwork::default();
n.vertices.push(figcraft_doc::Vertex::new(0.0, 0.0));
n.vertices.push(figcraft_doc::Vertex::new(10.0, 0.0));
let mut seg = figcraft_doc::Segment::line(0, 1);
seg.ta = (10.0, 0.0);
n.segments.push(seg);
node.kind = figcraft_doc::NodeKind::Vector { network: n };
node.transform = figcraft_geom::Affine::IDENTITY;
}
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
let doc = s.doc().unwrap().clone();
// Where the points are on the page now (entering edit mode may refit the layer's box).
let n = net(&s, id);
let w = doc.world_transform(id);
let (p0, p1) = (w * n.vertices[0].point(), w * n.vertices[1].point());
// Point 0's handle ends on point 1: there the handle; on point 0, the point.
assert_eq!(hit(&doc, &ve(&s), p1, 6.0), Some(VHit::Handle { seg: 0, start: true }));
assert_eq!(hit(&doc, &ve(&s), p0, 6.0), Some(VHit::Vertex { index: 0 }));
}
/// A short path's handles end a few pixels from their point: a click on the point is still on the
/// point (the nearer of the two wins), so the Bend tool can sharpen it again, while a click on the
/// handle's end still takes the handle.
#[test]
fn short_handles_leave_their_point_clickable() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [6, 10], [12, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.tool", json!({"tool": "bend"}));
let v = vertex_at(&net(&s, id), 6.0, 10.0);
click(&mut s, 6.0, 10.0);
let n = net(&s, id);
let curved = |n: &VectorNetwork| n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st).is_some_and(|h| h != (0.0, 0.0)));
assert!(curved(&n));
let reach = (6f64.powi(2) + 10f64.powi(2)).sqrt() / 3.0;
assert!(reach < 6.0, "the handles end within the 6 px hit tolerance: {reach}");
// A click on the handle's end takes the handle (nothing changes); one on the point sharpens it.
let (sg, st) = n.incident(v)[0];
let h = n.handle(sg, st).unwrap();
click(&mut s, 6.0 + h.0, 10.0 + h.1);
assert!(curved(&net(&s, id)), "the handle's end is the handle's");
click(&mut s, 6.0, 10.0);
let n = net(&s, id);
assert!(n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))), "the point is the point's: sharp again");
}
#[test]
fn paint_bucket_fills_and_unfills_areas() {
let mut s = Session::new();
+4 -3
View File
@@ -373,10 +373,11 @@ fn listed<'s>(cmd: &str, s: &'s Session, p: &Value, who: &str, people: &[&str])
/// Add the names `text` @mentions to `named` as `{name, known, you}`, each once.
fn push_mentions(named: &mut Vec<Value>, text: &str, people: &[&str], user: &str) {
// `known` and `you` follow from the name, so the name alone tells a repeat.
let mut seen: std::collections::BTreeSet<String> = named.iter().filter_map(|v| v.get("name")?.as_str().map(str::to_string)).collect();
for (name, known) in mentioned(text, people) {
let v = json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)});
if !named.contains(&v) {
named.push(v);
if seen.insert(name.to_string()) {
named.push(json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)}));
}
}
}
+3 -2
View File
@@ -42,8 +42,8 @@ fn set_track(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(CMD, format!("node {id} is not a grid auto layout frame")));
};
let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize).min(MAX_TRACKS);
if !(0.0..count as f64).contains(&index) {
return Err(bad(CMD, format!("`index` must be 0..{count}")));
if index.fract() != 0.0 || !(0.0..count as f64).contains(&index) {
return Err(bad(CMD, format!("`index` must be an integer in 0..{count}")));
}
let i = index as usize;
let track = match kind {
@@ -256,6 +256,7 @@ mod tests {
json!({"ids": [f], "axis": "diagonal", "index": 0, "sizing": "fixed"}),
json!({"ids": [f], "axis": "columns", "index": 9, "sizing": "fixed"}),
json!({"ids": [f], "axis": "columns", "index": -1, "sizing": "hug"}),
json!({"ids": [f], "axis": "columns", "index": 1.5, "sizing": "fixed", "value": 50}),
json!({"ids": [f], "axis": "columns", "index": 0, "sizing": "stretchy"}),
json!({"ids": [kids[1]], "axis": "columns", "index": 0, "sizing": "hug"}),
] {
+16 -3
View File
@@ -115,6 +115,8 @@ pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let norm: String = {
let mut out = String::new();
let mut up = false;
// Once, not per character: a long value made this quadratic before it was refused.
let no_lowercase = s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic());
for (i, ch) in s.trim().chars().enumerate() {
if ch == '_' || ch == '-' || ch == ' ' {
up = true;
@@ -125,7 +127,7 @@ pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
} else if up {
out.extend(ch.to_uppercase());
up = false;
} else if s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic()) {
} else if no_lowercase {
out.extend(ch.to_lowercase());
} else {
out.push(ch);
@@ -623,7 +625,15 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg);
}
"dashes" => {
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
let d: Vec<f64> = if v.is_null() {
Vec::new()
} else {
let a = v.as_array().ok_or_else(|| bad(cmd, "`dashes` must be an array of numbers"))?;
a.iter()
.map(|x| if x.is_boolean() { None } else { f64_of(x).map(|n| n.max(0.0)) })
.collect::<Option<_>>()
.ok_or_else(|| bad(cmd, "`dashes` must be an array of numbers"))?
};
doc.get_mut(id)?.stroke.dashes = d;
}
"dashCap" => {
@@ -953,6 +963,9 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
*if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on;
}
"leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?,
"wrapStyle" | "textWrap" => {
text_mut(cmd, doc.get_mut(id)?)?.wrap_style = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?
}
"maxLines" => {
let lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32;
let n = doc.get_mut(id)?;
@@ -1197,7 +1210,7 @@ pub fn override_group(k: &str) -> &'static str {
"name" => "name",
"characters" | "text" => "characters",
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
| "hangingPunctuation" | "hangingList" => "textStyle",
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" => "size",
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
+18 -4
View File
@@ -31,8 +31,8 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("proto.interaction.remove", "Remove interaction", "{id?, index}", true, |s, p| {
let cmd = "proto.interaction.remove";
let k = req_num(cmd, p, "index")?;
if k < 0.0 {
return Err(bad(cmd, "`index` must be ≥ 0"));
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
return Err(bad(cmd, "`index` must be a whole number in 0..1e9"));
}
let k = k as usize;
let mut hit = false;
@@ -406,8 +406,8 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "proto.interaction.set";
let p = &super::proto_actions::with_expressions(cmd, &s.doc()?.variables, p)?;
let k = req_num(cmd, p, "index")?;
if k < 0.0 {
return Err(bad(cmd, "`index` must be ≥ 0"));
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
return Err(bad(cmd, "`index` must be a whole number in 0..1e9"));
}
let k = k as usize;
let whole: Option<Interaction> = parse(cmd, p, "interaction")?;
@@ -536,6 +536,20 @@ mod tests {
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
}
#[test]
fn fractional_index_is_rejected() {
let mut s = Session::new();
let a = frame(&mut s, 0.0);
let b = frame(&mut s, 200.0);
s.execute("proto.interaction.add", &json!({"id": a, "dest": b})).unwrap();
s.execute("proto.interaction.add", &json!({"id": a, "dest": b})).unwrap();
assert!(s.execute("proto.interaction.remove", &json!({"id": a, "index": 0.5})).is_err());
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
let before = s.doc().unwrap().node(a).unwrap().interactions[0].clone();
assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 0.5, "easing": "linear"})).is_err());
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions[0], before);
}
#[test]
fn flows_add_rename_remove() {
let mut s = Session::new();
+11
View File
@@ -308,6 +308,17 @@ pub fn specs() -> Vec<CommandSpec> {
set_list(s, p, l, false)
}
),
spec!(
"text.wrapStyle",
"Wrap style",
"{style: auto|balance|pretty, ids?} where lines break when the text wraps: Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
true,
|s, p| {
let w: figcraft_doc::WrapStyle =
get(p, "style").and_then(super::props::enum_of).ok_or_else(|| bad("text.wrapStyle", "`style`: auto|balance|pretty"))?;
para(s, p, |t| t.wrap_style = w)
}
),
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_indent = v)
@@ -338,6 +338,15 @@ fn overridable_properties_stay_through_main_edits_and_reset() {
|n| text(n).paragraph_spacing.to_string(),
"a text property like the others",
),
(
"wrap style",
Layer::Title,
"textStyle",
json!({"wrapStyle": "balance"}),
Some(json!({"wrapStyle": "pretty"})),
|n| format!("{:?}", text(n).wrap_style),
"a text property like the others (#1827)",
),
(
"padding",
Layer::Row,
+11 -4
View File
@@ -189,14 +189,21 @@ fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), miss
/// Export each copy into `dir` as `<name>.<format>` at `scale`, adding `files` (and `errors`) to
/// `out`.
fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) {
let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::<String>::new());
let (mut files, mut errors) = (Vec::new(), Vec::new());
let mut used = std::collections::BTreeSet::<String>::new();
// The next number to try per base, so many copies of one name don't retry from 2 each time.
let mut next = std::collections::BTreeMap::<String, u64>::new();
for (copy, name) in ids {
// One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively,
// as macOS and Windows file systems compare names).
let base = file_name(name);
let stem =
(1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base);
used.push(stem.to_lowercase());
let from = next.get(&base.to_lowercase()).copied().unwrap_or(1);
let (k, stem) = (from..)
.map(|k| (k, if k == 1 { base.clone() } else { format!("{base} {k}") }))
.find(|(_, n)| !used.contains(&n.to_lowercase()))
.unwrap_or((from, base.clone()));
next.insert(base.to_lowercase(), k + 1);
used.insert(stem.to_lowercase());
let path = std::path::Path::new(dir).join(format!("{stem}.{format}")).to_string_lossy().to_string();
match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": scale, "path": path})) {
Ok(_) => files.push(path),
+15
View File
@@ -69,3 +69,18 @@ fn inspect_shows_it_and_copied_properties_carry_it() {
s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap();
assert_eq!(cap(&s, b), None);
}
#[test]
fn node_set_rejects_bad_dashes_and_keeps_the_stroke() {
let mut s = Session::new();
let l = line(&mut s);
let dashes = |s: &Session| s.doc().unwrap().node(l).unwrap().stroke.dashes.clone();
for bad in [json!([5, "x", 20]), json!([5, true, 20]), json!([5, false, 20]), json!("abc"), json!(5)] {
assert!(s.execute("node.set", &json!({"id": l, "props": {"dashes": bad}})).is_err());
assert_eq!(dashes(&s), vec![20.0, 20.0], "a rejected value changes nothing");
}
s.execute("node.set", &json!({"id": l, "props": {"dashes": [5, 20]}})).unwrap();
assert_eq!(dashes(&s), vec![5.0, 20.0]);
s.execute("node.set", &json!({"id": l, "props": {"dashes": null}})).unwrap();
assert!(dashes(&s).is_empty());
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub use css::{CssNames, css_var_name, css_var_of};
use css::{css, tok};
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
use html::html;
pub(crate) use html::{Markup, class_name};
pub(crate) use html::{Markup, Names, class_name};
pub(crate) use swift::swift;
pub fn specs() -> Vec<CommandSpec> {
+5
View File
@@ -302,6 +302,11 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
TextAlignH::Right => put("text-align", "right".into()),
TextAlignH::Justified => put("text-align", "justify".into()),
}
match tx.wrap_style {
figcraft_doc::WrapStyle::Auto => {}
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
}
match s.case {
TextCase::Original => {}
TextCase::Upper => put("text-transform", "uppercase".into()),
+30 -8
View File
@@ -30,7 +30,7 @@ fn kind_word(n: &Node) -> &str {
/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
/// digit keeps the `layer-` prefix.
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> String {
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Names) -> String {
let mut base = String::new();
for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
@@ -57,11 +57,30 @@ pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> Stri
} else {
base.to_string()
};
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
used.push(name.clone());
let from = used.next.get(&base).copied().unwrap_or(1);
let (k, name) = (from..)
.map(|k| (k, if k == 1 { base.clone() } else { format!("{base}-{k}") }))
.find(|(_, n)| !used.used.contains(n))
.unwrap_or((from, base.clone()));
used.next.insert(base, k + 1);
used.insert(name.clone());
name
}
/// The names [`class_name`] has given out, and the next suffix to try per base, so many layers
/// sharing one name don't each retry every suffix from 2 (that was cubic in their number).
#[derive(Default)]
pub(crate) struct Names {
used: std::collections::BTreeSet<String>,
next: std::collections::BTreeMap<String, u64>,
}
impl Names {
pub fn insert(&mut self, name: String) {
self.used.insert(name);
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('\n', "<br>")
}
@@ -90,7 +109,10 @@ pub(super) fn html(doc: &Document, id: NodeId) -> String {
let ctx = Ctx::new(doc, id);
let mut out = Markup::new(&ctx, CodeLang::Html);
out.node(id, None, 0, "");
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
// Document text can reach the stylesheet through any property: `<\/` reads back as `</` inside a CSS
// string and can never close the element early.
let css = format!("{}{}", out.tokens.root_rule(), out.styles).replace("</", "<\\/");
format!("{}\n<style>\n{css}</style>\n", out.markup)
}
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue
@@ -101,7 +123,7 @@ pub(crate) struct Markup<'c, 'a> {
ctx: &'c Ctx<'a>,
/// Html, React, Vue or Leptos.
lang: CodeLang,
used: Vec<String>,
used: Names,
pub markup: String,
pub styles: String,
pub tokens: Tokens,
@@ -158,7 +180,7 @@ impl<'c, 'a> Markup<'c, 'a> {
Markup {
ctx,
lang,
used: Vec::new(),
used: Names::default(),
markup: String::new(),
styles: String::new(),
tokens: Tokens::default(),
@@ -273,7 +295,7 @@ impl<'c, 'a> Markup<'c, 'a> {
let root = n.id == self.ctx.root;
let class = match self.root_class.take().filter(|_| root) {
Some(c) => {
self.used.push(c.clone());
self.used.insert(c.clone());
c
}
None => {
@@ -329,7 +351,7 @@ impl<'c, 'a> Markup<'c, 'a> {
let open = open("div");
let slot_ident = slot.map(|p| match self.ctx.comp {
Some(c) => crate::component_codegen::slot_name(c, p),
None => class_name(&p.ident, "slot", &mut Vec::new()),
None => class_name(&p.ident, "slot", &mut Names::default()),
});
if let Some(p) = slot.filter(|_| kids.is_empty()) {
let content = if leptos {
+8 -6
View File
@@ -11,7 +11,9 @@ use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
use crate::codegen::{
CodeLang, Ctx, FLUTTER_IMPORT, Markup, Names, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift,
};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
use crate::params::{id_of, string, targets};
use crate::{bad, spec};
@@ -616,14 +618,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
/// The `<slot name="…">` of a slot parameter: unique among the component's slot parameters, unlike
/// `class_name` called with a fresh list per parameter (two idents can lowercase alike).
pub(crate) fn slot_name(c: &Component, p: &Param) -> String {
let mut used = Vec::new();
let mut used = Names::default();
for q in c.params.iter().filter(|q| matches!(q.kind, ParamKind::Slot)) {
let name = class_name(&q.ident, "slot", &mut used);
if q.ident == p.ident {
return name;
}
}
class_name(&p.ident, "slot", &mut Vec::new())
class_name(&p.ident, "slot", &mut Names::default())
}
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
@@ -720,7 +722,7 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
.filter(|(name, _, _)| is_slot(name))
.map(|(name, expr, _)| {
let slot = c.params.iter().find(|q| q.ident == *name);
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Vec::new()));
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Names::default()));
format!("{pad} <template #{name}>\n{expr}{pad} </template>\n")
})
.collect();
@@ -823,7 +825,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
let mut used = Names::default();
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, "prop", &mut used))).collect()
}
@@ -832,7 +834,7 @@ fn data_attrs(variants: &[&Param]) -> Vec<String> {
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, "component", &mut Vec::new());
let root_class = class_name(&root_name, "component", &mut Names::default());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
+4 -2
View File
@@ -288,8 +288,10 @@ pub fn layer_changes(before: &Document, now: &Document, id: NodeId) -> Vec<PropC
fn order(before: &Document, now: &Document, id: NodeId) -> Option<(usize, usize)> {
let parent = now.node(id)?.parent?;
let (pb, pa) = (before.node(parent)?, now.node(parent)?);
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| pa.children.contains(c)).collect();
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| pb.children.contains(c)).collect();
let (in_b, in_a): (std::collections::BTreeSet<NodeId>, std::collections::BTreeSet<NodeId>) =
(pb.children.iter().copied().collect(), pa.children.iter().copied().collect());
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| in_a.contains(c)).collect();
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| in_b.contains(c)).collect();
let (ib, ia) = (common_b.iter().position(|c| *c == id)?, common_a.iter().position(|c| *c == id)?);
(ib != ia).then_some((ib + 1, ia + 1))
}
+2
View File
@@ -310,6 +310,8 @@ fn decl(key: &str, value: &str) -> Vec<(String, String)> {
("text-transform", "uppercase") => kw("uppercase"),
("text-transform", "lowercase") => kw("lowercase"),
("text-transform", "capitalize") => kw("capitalize"),
("text-wrap", "balance") => kw("text-balance"),
("text-wrap", "pretty") => kw("text-pretty"),
("text-decoration", "underline") => kw("underline"),
("text-decoration", "line-through") => kw("line-through"),
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
+5 -2
View File
@@ -127,12 +127,15 @@ pub fn topics(v: Option<&Value>) -> Result<Vec<String>, String> {
for t in list {
let Some(s) = t.as_str() else { return Err("`topics` must be an array of strings".into()) };
match s {
// Each once: the list is scanned for every retained event under the log's lock, so
// a long list of repeats would multiply that work.
"comments" | "document" | "all" if out.iter().any(|t| t == s) => {}
"comments" | "document" | "all" => out.push(s.to_string()),
other => return Err(format!("unknown topic `{other}` (comments, document, all)")),
}
}
if out.is_empty() {
out.push("all".into());
if out.is_empty() || out.iter().any(|t| t == "all") {
out = vec!["all".into()];
}
Ok(out)
}
+4
View File
@@ -193,6 +193,10 @@ fn topics_parse_and_reject_unknown_names() {
assert_eq!(events::topics(Some(&json!([]))).unwrap(), vec!["all".to_string()]);
assert_eq!(events::topics(Some(&json!(["comments", "document"]))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
assert!(events::topics(Some(&json!(["nope"]))).is_err());
// Repeats collapse (the list is scanned per event under the log's lock), and `all` is all.
let many: Vec<&str> = std::iter::repeat_n("comments", 10_000).chain(["document"]).collect();
assert_eq!(events::topics(Some(&json!(many))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
assert_eq!(events::topics(Some(&json!(["comments", "all", "document"]))).unwrap(), vec!["all".to_string()]);
assert!(events::topics(Some(&json!("comments"))).is_err());
}
+10
View File
@@ -149,6 +149,16 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) {
return Some(format!("`{command}` isn't available to plugins"));
}
// A preview runs its inner command, which the plugin's permissions and the denied list must cover too.
// An inner command that can't be previewed (a nested preview) is refused by the preview itself,
// before it runs: descending into its parameters first would only rescan them.
if command == "text.preview"
&& let Some(inner) = get(params, "command").and_then(Value::as_str)
&& crate::textpreview::PREVIEWABLE.contains(&inner)
&& let Some(why) = refusal(m, inner, get(params, "params").unwrap_or(&Value::Null))
{
return Some(why);
}
// A widget's own data is FigCraft's to write, so no plugin can forge or retarget a widget.
if writes_widget_data(params, 0) {
return Some(format!("plugins can't write `{}…` layer data", crate::widgets::DATA_PREFIX));
+3 -4
View File
@@ -157,8 +157,9 @@ fn state_of(doc: &Document, w: NodeId) -> Map<String, Value> {
/// fields), each once, in layer order.
fn names(doc: &Document, w: NodeId, key: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) {
if !out.contains(&t) {
if seen.insert(t.clone()) {
out.push(t);
}
}
@@ -243,9 +244,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) {
return Err(bad(cmd, "the layer is no longer this plugin's widget"));
}
for c in n.children.clone() {
doc.remove(c)?;
}
doc.remove_children(w)?;
crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?;
let n = doc.get_mut(w)?;
n.locked = false;
+27
View File
@@ -73,6 +73,20 @@ fn non_ascii_layer_names_transliterate_into_readable_classes() {
assert!(code.contains("<p class=\"rogeuin\">") && code.contains("<p class=\"rogeuin-2\">"), "{code}");
}
#[test]
fn many_layers_with_one_name_get_numbered_in_linear_time() {
// Each name used to retry every suffix from 2 against a list: cubic in their number.
let mut used = crate::codegen::Names::default();
let start = std::time::Instant::now();
let names: Vec<String> = (0..20_000).map(|_| crate::codegen::class_name("Card / Title", "frame", &mut used)).collect();
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
assert_eq!(names[0], "card-title");
assert_eq!(names[1], "card-title-2");
assert_eq!(names[19_999], "card-title-20000");
let distinct: std::collections::BTreeSet<&String> = names.iter().collect();
assert_eq!(distinct.len(), names.len());
}
#[test]
fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() {
let mut s = Session::new();
@@ -163,3 +177,16 @@ fn rem_preserves_text_property_default() {
}
}
}
#[test]
fn the_stylesheet_never_contains_a_closing_style_tag_from_document_text() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let t = id(s.execute("text.create", &json!({"text": "x", "x": 0, "y": 0})).unwrap());
s.execute("node.set", &json!({"id": t, "props": {"fontFamily": "a</style><script>alert(1)</script>"}})).unwrap();
s.execute("node.reparent", &json!({"ids": [t], "parent": f})).unwrap();
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
let (_, css) = code.split_once("<style>").unwrap();
assert_eq!(css.matches("</style>").count(), 1, "{code}");
assert!(!code.contains("<script>"), "{code}");
}
@@ -277,6 +277,11 @@ fn renaming_several_layers_by_the_documented_codes() {
assert_eq!(r(None, "$n", Some(1e30), "a"), "1000000000000");
assert_eq!(r(None, "$n", Some(f64::NAN), "a"), "1");
assert_eq!(r(Some("é"), "e", None, "café é"), "cafe e");
// A template is expanded once per match, so its length is capped (1,000 characters).
let long = "$nn".repeat(400);
assert!(crate::rename::BatchRename::new(Some("a"), false, &long, None, None).is_err());
assert!(crate::rename::BatchRename::new(None, false, "x", Some(&long), None).is_err_and(|e| e.contains("pattern")));
assert!(crate::rename::BatchRename::new(Some("a"), false, &"$nn".repeat(300), None, None).is_ok());
}
#[test]
+24
View File
@@ -153,6 +153,30 @@ fn read_only_plugins_and_allowlists_are_enforced() {
assert_eq!(node(&s, a).name, "Rectangle");
}
#[test]
fn a_preview_is_held_to_the_permissions_of_the_command_it_shows() {
let mut s = Session::new();
let a = rect(&mut s, 0.0);
// Only text.preview is allowed: the node.set it would run is not.
install(
&mut s,
manifest("org.test.previewer", "write", &["text.preview"]),
&script(&[("text.preview", json!({"command": "node.set", "params": {"id": a, "props": {"name": "x"}}}))], OnRefusal::Fail, ""),
);
let e = run_err(&mut s, "org.test.previewer");
assert!(e.contains("`node.set` isn't in the commands"), "{e}");
assert_eq!(node(&s, a).name, "Rectangle");
}
#[test]
fn a_nested_preview_is_refused_by_the_preview_itself() {
let mut s = Session::new();
let nested = json!({"command": "text.preview", "params": {"command": "node.set", "params": {"props": {"name": "x"}}}});
install(&mut s, manifest("org.test.nested", "write", &["text.preview"]), &script(&[("text.preview", nested)], OnRefusal::Fail, ""));
let e = run_err(&mut s, "org.test.nested");
assert!(e.contains("can't be previewed"), "{e}");
}
#[test]
fn files_dialogs_history_and_plugins_are_off_limits() {
let mut s = Session::new();
+9
View File
@@ -9,6 +9,9 @@ use figcraft_doc::{Document, NodeId};
/// Longest match pattern accepted (characters).
const MAX_FIND: usize = 1_000;
/// Longest replacement or pattern template accepted (characters): each is expanded again for
/// every match, so its length multiplies the work of a rename.
const MAX_TEMPLATE: usize = 1_000;
/// Longest name a rename produces (characters).
const MAX_NAME: usize = 10_000;
/// Widest zero padding of a number code.
@@ -50,6 +53,12 @@ impl BatchRename {
}
Some(f) => Some(Find::Text(f.to_string())),
};
if replace.chars().count() > MAX_TEMPLATE {
return Err(format!("the replacement is longer than {MAX_TEMPLATE} characters"));
}
if pattern.is_some_and(|p| p.chars().count() > MAX_TEMPLATE) {
return Err(format!("the pattern is longer than {MAX_TEMPLATE} characters"));
}
let start = start.filter(|s| s.is_finite()).map_or(1, |s| s.clamp(0.0, 1e12).round() as u64);
Ok(Self { find, replace: replace.to_string(), pattern: pattern.map(str::to_string), start })
}
+9
View File
@@ -217,6 +217,15 @@ fn remove_mode_and_escape() {
assert!(s.execute("shapeBuilder.mode", &json!({"mode": "sideways"})).is_err());
}
#[test]
fn a_group_named_many_times_is_walked_once() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
let r = ex(&mut s, "shapeBuilder.regions", json!({"ids": vec![g; 500]}));
assert_eq!(r["sources"], json!([a, b]));
}
#[test]
fn groups_rotated_and_ineligible_layers() {
let mut s = Session::new();
+66
View File
@@ -371,6 +371,72 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
}
#[test]
fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
// Explore text properties: "Auto: Figma fits as many words as possible on each line before
// wrapping to the next." "Balance: Figma distributes text as evenly as possible across every
// line, so each line is close to the same length." "Pretty: Figma keeps each line full, but
// adjusts the last four lines to avoid an orphan -- a single word stranded alone on the last
// line." The synthetic font: 8 px per character at 16 px.
let font = test_mono_font();
let mut s = Session::new();
let t = text(&mut s, "aaa bbb ccc ddd eee", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 136}})).unwrap();
let lines = |s: &Session| -> Vec<(usize, usize)> {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
l.lines.iter().map(|l| (l.char_start, l.char_end)).collect()
};
// 136 px holds 17 characters: Auto fills the first line with four words and strands the fifth.
assert_eq!(lines(&s), [(0, 16), (16, 19)]);
// Balance: as even as the words allow, three and two.
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
assert_eq!(lines(&s), [(0, 12), (12, 19)]);
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["wrapStyle"], json!("balance"));
// Pretty: the stranded word gets company from the line before.
s.execute("text.wrapStyle", &json!({"ids": [t], "style": "pretty"})).unwrap();
assert_eq!(lines(&s), [(0, 12), (12, 19)]);
// Three lines of two-letter words in a 72 px box (nine characters): Auto strands "gg"; Pretty
// narrows the line before it alone ("aa bb cc" / "dd ee" / "ff gg"); Balance's narrowest
// width that still gives three lines (64 px) fills the first two lines as Auto does.
s.execute("node.set", &json!({"id": t, "props": {"characters": "aa bb cc dd ee ff gg", "width": 72, "wrapStyle": "auto"}})).unwrap();
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 20)]);
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
assert_eq!(lines(&s), [(0, 9), (9, 15), (15, 20)]);
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 20)]);
// A last line of two words is no orphan: Pretty leaves Auto's lines; a text on one line is
// left alone whatever the style.
s.execute("node.set", &json!({"id": t, "props": {"characters": "aa bb cc dd ee ff gg hh", "wrapStyle": "pretty"}})).unwrap();
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 23)]);
s.execute("node.set", &json!({"id": t, "props": {"width": 400, "wrapStyle": "balance"}})).unwrap();
assert_eq!(lines(&s), [(0, 23)]);
// Dev Mode's CSS says `text-wrap` for Balance and Pretty and nothing for Auto; junk is refused.
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
assert!(css(&mut s).contains("text-wrap: balance;"), "{}", css(&mut s));
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
assert!(css(&mut s).contains("text-wrap: pretty;"));
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "auto"}})).unwrap();
assert!(!css(&mut s).contains("text-wrap"));
assert!(s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "zigzag"}})).is_err());
assert!(s.execute("text.wrapStyle", &json!({"ids": [t], "style": "even"})).is_err());
// A word as wide as the box is never broken in two by Balance or Pretty: 80 px holds the ten
// letters, and every narrower width Balance could try would have to split them.
s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghij klm", "width": 80, "wrapStyle": "balance"}})).unwrap();
assert_eq!(lines(&s), [(0, 11), (11, 14)]);
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
assert_eq!(lines(&s), [(0, 11), (11, 14)]);
// Hanging punctuation: a line starting with an opening mark has that much more room, and
// Pretty narrows it by as much, so the line before the orphan still gives a word away.
s.execute(
"node.set",
&json!({"id": t, "props": {"characters": "aa bb cc (dd ee ff gg", "width": 72, "hangingPunctuation": true, "wrapStyle": "auto"}}),
)
.unwrap();
assert_eq!(lines(&s), [(0, 9), (9, 19), (19, 21)]);
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
assert_eq!(lines(&s), [(0, 9), (9, 16), (16, 21)]);
}
#[test]
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
+17 -9
View File
@@ -72,6 +72,9 @@ use kiwi::{Kv, Schema};
/// Largest `canvas.fig` member read, and largest decompressed chunk.
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
/// Largest decompressed schema chunk: Figma's schema unpacks to well under 1 MB, so a schema that
/// inflates past this is refused before its definitions are decoded.
const MAX_SCHEMA: u64 = 16 * 1024 * 1024;
/// Largest image member read.
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
@@ -324,7 +327,7 @@ fn decode_canvas(canvas: &[u8]) -> Result<(Schema, Vec<u8>), FormatError> {
let (Some(schema), Some(message)) = (it.next(), it.next()) else {
return Err(corrupt("canvas.fig needs a schema and a message chunk"));
};
let schema = Schema::decode(&inflate(schema)?).map_err(|e| corrupt(e.to_string()))?;
let schema = Schema::decode(&inflate_within(schema, MAX_SCHEMA)?).map_err(|e| corrupt(e.to_string()))?;
Ok((schema, inflate(message)?))
}
@@ -347,18 +350,23 @@ fn chunks(canvas: &[u8]) -> Result<Vec<&[u8]>, FormatError> {
/// Decompress a chunk: zstd (by its magic number), else raw deflate, else zlib.
fn inflate(data: &[u8]) -> Result<Vec<u8>, FormatError> {
fn capped(r: impl Read) -> Option<Vec<u8>> {
inflate_within(data, MAX_CHUNK)
}
/// [`inflate`] refusing a chunk that unpacks to more than `max` bytes.
fn inflate_within(data: &[u8], max: u64) -> Result<Vec<u8>, FormatError> {
let capped = |r: &mut dyn Read| -> Option<Vec<u8>> {
let mut out = Vec::new();
r.take(MAX_CHUNK + 1).read_to_end(&mut out).ok()?;
(out.len() as u64 <= MAX_CHUNK).then_some(out)
}
r.take(max.saturating_add(1)).read_to_end(&mut out).ok()?;
(out.len() as u64 <= max).then_some(out)
};
if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
let mut src = data;
let dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
return capped(dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
let mut dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
return capped(&mut dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
}
capped(flate2::read::DeflateDecoder::new(data))
.or_else(|| capped(flate2::read::ZlibDecoder::new(data)))
capped(&mut flate2::read::DeflateDecoder::new(data))
.or_else(|| capped(&mut flate2::read::ZlibDecoder::new(data)))
.ok_or_else(|| corrupt("damaged or oversized deflate chunk"))
}
+4 -1
View File
@@ -91,7 +91,10 @@ impl<'d> Exporter<'d> {
NodeKind::Group => f.push(("resizeToFit", b(true))),
NodeKind::Section { contents_hidden } => f.push(("sectionContentsHidden", b(*contents_hidden))),
NodeKind::Ellipse { start, sweep, inner } => {
f.push(("arcData", o(vec![("startingAngle", fl(*start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))])))
// Within a turn, where a 32-bit angle still holds the start and the sweep exactly
// enough; a start past a turn draws the same.
let start = crate::rest::start_within_a_turn(*start);
f.push(("arcData", o(vec![("startingAngle", fl(start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))])))
}
NodeKind::Polygon { count } => f.push(("count", u(*count))),
NodeKind::Star { count, inner_ratio } => f.extend([("count", u(*count)), ("starInnerScale", fl(*inner_ratio))]),
+24
View File
@@ -1611,3 +1611,27 @@ fn a_whole_ellipse_starting_anywhere_comes_back_whole() {
}
}
}
/// An ellipse whose start has gone round many times (a start typed in degrees past a few hundred
/// turns) comes back drawn the same: the same sweep, and a start the same way round.
#[test]
fn an_ellipse_starting_many_turns_round_comes_back_the_same() {
let tau = std::f64::consts::TAU;
// Not all of them hold exactly as 32-bit floats, the way a start typed in degrees doesn't.
for start in [999.0, 1500.0, -5000.0, 1e6, 1e6 + 0.1, 1234.567] {
for sweep in [tau, -tau, 1.0, -2.5, 1.3, tau - 0.1] {
let mut doc = Document::new("turns");
let page = doc.first_page().unwrap();
doc.create(page, NodeKind::Ellipse { start, sweep, inner: 0.0 }, "Ellipse", 0.0, 0.0, 100.0, 100.0).unwrap();
let (bytes, _) = export(&doc, None).unwrap();
let (back, _) = import(&bytes, "x").unwrap();
let kinds: Vec<NodeKind> =
back.ids().filter_map(|id| back.node(id)).map(|n| n.kind.clone()).filter(|k| matches!(k, NodeKind::Ellipse { .. })).collect();
let Some(NodeKind::Ellipse { start: s, sweep: w, .. }) = kinds.first() else { panic!("start {start}: no ellipse") };
// Where the arc starts, the same way round (as close as a 32-bit angle holds it).
let off = (s - start).rem_euclid(tau);
assert!(off.min(tau - off) < 1e-5, "start {start}, sweep {sweep}: came back starting at {s}");
assert!((w - sweep).abs() < 1e-5, "start {start}, sweep {sweep}: came back sweeping {w}");
}
}
}
+29 -5
View File
@@ -6,6 +6,7 @@
//! Everything here reads untrusted bytes: reads are bounds-checked, nesting is capped, array
//! counts can't exceed the bytes left, and the total number of decoded values is capped.
use std::collections::HashMap;
use std::fmt;
/// Deepest struct/message nesting decoded.
@@ -32,6 +33,9 @@ const MAX_VALUES: usize = 32 << 20;
/// Most definitions in a schema, and fields in one definition.
const MAX_DEFS: usize = 10_000;
const MAX_FIELDS: usize = 10_000;
/// Most fields in a schema, every definition together: Figma's own schema has a few thousand, and
/// without a total a schema of [`MAX_DEFS`] wide definitions could hold 100 million field records.
const MAX_SCHEMA_FIELDS: usize = 200_000;
#[derive(Debug, PartialEq, Eq)]
pub struct KiwiError(pub String);
@@ -252,6 +256,7 @@ impl Schema {
}
let mut defs = Vec::with_capacity(count);
let mut raw_types = Vec::new();
let mut total_fields = 0usize;
for _ in 0..count {
let name = r.string()?;
let kind = match r.byte()? {
@@ -261,7 +266,8 @@ impl Schema {
k => return err(format!("schema: unknown definition kind {k}")),
};
let n = r.var_uint()? as usize;
if n > MAX_FIELDS || n > r.left() {
total_fields = total_fields.saturating_add(n);
if n > MAX_FIELDS || n > r.left() || total_fields > MAX_SCHEMA_FIELDS {
return err("schema: too many fields");
}
let mut fields = Vec::with_capacity(n);
@@ -308,7 +314,7 @@ impl Schema {
/// [`Self::decode_message`] within `budget` values (see [`value_budget`]).
pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result<Kv<'s>, KiwiError> {
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget };
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget, ids: vec![None; self.defs.len()] };
d.def(root, 0)
}
}
@@ -322,6 +328,10 @@ struct Decoder<'s, 'b> {
schema: &'s Schema,
r: Reader<'b>,
budget: usize,
/// Per definition, the index of the first field with each id (message field ids, enum
/// values), built the first time the definition is decoded: a message can carry many tags
/// against a wide definition, and a scan per tag would multiply the two.
ids: Vec<Option<HashMap<u32, usize>>>,
}
impl<'s> Decoder<'s, '_> {
@@ -330,6 +340,19 @@ impl<'s> Decoder<'s, '_> {
Ok(())
}
/// The first field of definition `i` with id (or enum value) `id`.
fn field_by_id(&mut self, i: usize, id: u32) -> Option<&'s Field> {
let fields = &self.schema.defs.get(i)?.fields;
let ids = self.ids.get_mut(i)?.get_or_insert_with(|| {
let mut m = HashMap::with_capacity(fields.len());
for (j, f) in fields.iter().enumerate() {
m.entry(f.value).or_insert(j);
}
m
});
fields.get(*ids.get(&id)?)
}
fn def(&mut self, i: usize, depth: usize) -> Result<Kv<'s>, KiwiError> {
if depth > MAX_DEPTH {
return err("message: nested too deeply");
@@ -340,7 +363,7 @@ impl<'s> Decoder<'s, '_> {
match d.kind {
Kind::Enum => {
let v = self.r.var_uint()?;
Ok(d.fields.iter().find(|f| f.value == v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
Ok(self.field_by_id(i, v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
}
Kind::Struct => {
let mut out = Vec::with_capacity(d.fields.len());
@@ -358,7 +381,7 @@ impl<'s> Decoder<'s, '_> {
out.shrink_to_fit();
return Ok(Kv::Object(out));
}
let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
let f = self.field_by_id(i, id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
out.push((f.name.as_str(), self.field(f, depth)?));
}
}
@@ -379,8 +402,9 @@ impl<'s> Decoder<'s, '_> {
if n > self.r.left() {
return err("message: array longer than the data");
}
// The field itself counts, so empty arrays can't pile up for free.
self.spend(1)?;
if ty == Type::Byte {
self.spend(1)?;
return Ok(Kv::Bytes(self.r.take(n)?.to_vec()));
}
self.spend(n)?;
+54
View File
@@ -1179,6 +1179,60 @@ fn scalar_fields_count_against_the_value_budget() {
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
}
#[test]
fn empty_arrays_count_against_the_value_budget() {
// 1000 empty `uint[]` fields: each field is a value even with no elements, so a small budget
// can't be outgrown by piling up empty containers.
let fields = vec![Field { name: "a".into(), ty: Some(Type::Uint), array: true, value: 1 }];
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
let mut data = [1u8, 0].repeat(1000);
data.push(0);
let e = sc.decode_message_within(0, &data, 10).unwrap_err();
assert_eq!(e.0, "message: too many values");
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
}
#[test]
fn message_tags_find_their_field_by_id_in_a_wide_definition() {
// A wide definition: every tag is looked up by id, and the first field with an id wins, as
// before the lookup was indexed.
let mut fields: Vec<Field> = (1..=5000).map(|i| Field { name: format!("f{i}"), ty: Some(Type::Bool), array: false, value: i }).collect();
fields.push(Field { name: "shadow".into(), ty: Some(Type::Bool), array: false, value: 7 });
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
let mut w = encode::Writer::default();
for _ in 0..20_000 {
w.var_uint(5000);
w.byte(1);
}
w.var_uint(7);
w.byte(0);
w.var_uint(0);
let Kv::Object(out) = sc.decode_message(0, &w.0).unwrap() else { panic!() };
assert_eq!(out.len(), 20_001);
assert!(out.iter().take(20_000).all(|(n, v)| *n == "f5000" && *v == Kv::Bool(true)));
assert_eq!(out.last().map(|(n, _)| *n), Some("f7"));
}
#[test]
fn a_schema_is_refused_past_its_total_field_count() {
// 25 definitions of 10 000 fields each: every one is within the per-definition cap, but
// together they pass the schema's total.
let mut w = encode::Writer::default();
w.var_uint(25);
for d in 0..25 {
w.string(&format!("D{d}"));
w.byte(0);
w.var_uint(10_000);
for i in 0..10_000u32 {
w.string("");
w.var_int(0);
w.byte(0);
w.var_uint(i);
}
}
assert_eq!(Schema::decode(&w.0).unwrap_err().0, "schema: too many fields");
}
#[test]
fn hostile_input_is_refused() {
// A message that contains itself, nested past the depth cap.
+5
View File
@@ -1028,6 +1028,11 @@ pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Resul
let tmp = std::path::PathBuf::from(name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(mut file) => {
// The replacement keeps the destination's permissions from the start, so a private
// file's content is never readable under default ones (the open handle still writes).
if let Ok(old) = std::fs::metadata(path) {
let _ = file.set_permissions(old.permissions());
}
if let Err(e) = file.write_all(bytes) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
+78 -14
View File
@@ -2030,27 +2030,51 @@ fn token_theme_groups<'a>(value: &'a Value, tokens: &BTreeMap<&str, BTreeMap<Str
}
/// Drops the resolved tokens that alias a variable which isn't being made, until none does; how
/// many were dropped.
/// many were dropped. Each token is looked at once to start with, and again only when a variable
/// it aliases goes: a chain of aliases dropped one link per round would otherwise rescan every
/// token per link.
fn drop_dangling_aliases(
resolved: &mut BTreeMap<&str, (usize, VariableType, BTreeMap<String, VariableValue>)>,
var_id: &dyn Fn(&str) -> String,
) -> usize {
let mut dropped = 0;
loop {
let ids: BTreeSet<String> = resolved.keys().map(|p| var_id(p)).collect();
let dangling: Vec<&str> = resolved
.iter()
.filter(|(_, (_, _, values))| values.values().any(|v| matches!(v, VariableValue::Alias(target) if !ids.contains(target))))
.map(|(path, _)| *path)
.collect();
if dangling.is_empty() {
return dropped;
// How many tokens make each variable id, and the tokens aliasing each id.
let mut makers: BTreeMap<String, usize> = BTreeMap::new();
for path in resolved.keys() {
*makers.entry(var_id(path)).or_default() += 1;
}
let mut aliased_by: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
let mut gone: Vec<&str> = Vec::new();
for (path, (_, _, values)) in resolved.iter() {
let mut dangling = false;
for v in values.values() {
if let VariableValue::Alias(target) = v {
dangling |= !makers.contains_key(target);
aliased_by.entry(target.as_str()).or_default().push(*path);
}
}
dropped += dangling.len();
for path in dangling {
resolved.remove(path);
if dangling {
gone.push(*path);
}
}
let mut dropped: BTreeSet<&str> = gone.iter().copied().collect();
while let Some(path) = gone.pop() {
let id = var_id(path);
let Some(left) = makers.get_mut(&id) else { continue };
*left -= 1;
if *left > 0 {
continue;
}
makers.remove(&id);
for &by in aliased_by.get(id.as_str()).into_iter().flatten() {
if dropped.insert(by) {
gone.push(by);
}
}
}
for path in &dropped {
resolved.remove(path);
}
dropped.len()
}
/// Moves a base token whose expression refers to a themed token into that token's collection, so
@@ -6515,3 +6539,43 @@ mod tests {
assert_eq!(button.interactions.len(), 1, "the prototype link survives the round trip");
}
}
#[cfg(test)]
mod dangling_alias_tests {
use super::*;
type Resolved<'a> = BTreeMap<&'a str, (usize, VariableType, BTreeMap<String, VariableValue>)>;
fn token(alias: Option<&str>) -> (usize, VariableType, BTreeMap<String, VariableValue>) {
let value = alias.map_or(VariableValue::Number(1.0), |a| VariableValue::Alias(format!("v:{a}")));
(0, VariableType::Number, [("m".to_string(), value)].into())
}
#[test]
fn a_chain_ending_in_a_missing_variable_is_dropped_whole() {
// t0 → t1 → … → t1999 → missing: every link goes, in one pass along the chain.
let names: Vec<String> = (0..2000).map(|i| format!("t{i}")).collect();
let mut resolved: Resolved = BTreeMap::new();
for (i, n) in names.iter().enumerate() {
let next = names.get(i + 1).map_or("missing", String::as_str);
resolved.insert(n, token(Some(next)));
}
resolved.insert("plain", token(None));
resolved.insert("to-plain", token(Some("plain")));
let var_id = |p: &str| format!("v:{p}");
assert_eq!(drop_dangling_aliases(&mut resolved, &var_id), 2000);
assert_eq!(resolved.keys().copied().collect::<Vec<_>>(), ["plain", "to-plain"]);
}
#[test]
fn a_variable_made_by_two_tokens_stays_while_one_does() {
// `a` and `b` both make `v:x`; `a` dangles, `b` doesn't, so `c` (aliasing `v:x`) stays.
let mut resolved: Resolved = BTreeMap::new();
resolved.insert("a", token(Some("missing")));
resolved.insert("b", token(None));
resolved.insert("c", token(Some("x")));
let var_id = |p: &str| if p == "a" || p == "b" { "v:x".to_string() } else { format!("v:{p}") };
assert_eq!(drop_dangling_aliases(&mut resolved, &var_id), 1);
assert_eq!(resolved.keys().copied().collect::<Vec<_>>(), ["b", "c"]);
}
}
+37 -6
View File
@@ -626,15 +626,27 @@ impl<'a> Ctx<'a> {
"ELLIPSE" => {
let a = j.get("arcData");
let arc = |k: &str| a.and_then(|a| num(a, k));
let start = arc("startingAngle").unwrap_or(0.0).clamp(-1e3, 1e3);
let end = arc("endingAngle").map_or(start + std::f64::consts::TAU, |e| e.clamp(-1e3, 1e3));
let tau = std::f64::consts::TAU;
// Capped only so the difference stays finite: the sweep is the difference of the
// angles as written, wherever they lie.
let start = arc("startingAngle").unwrap_or(0.0).clamp(-1e15, 1e15);
// No end goes all the way round, however far out the start is.
let end = arc("endingAngle").map(|e| e.clamp(-1e15, 1e15));
// The angles are 32-bit floats: a whole turn from anywhere but zero comes out a
// hair short of 2π as often as not. Within their precision of a turn, it is one.
let precision = (start.abs() + end.abs() + tau) * f64::from(f32::EPSILON);
let sweep = end - start;
let sweep = if sweep.abs() >= tau - precision { tau.copysign(sweep) } else { sweep };
NodeKind::Ellipse { start, sweep: sweep.clamp(-tau, tau), inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0) }
// Far enough out (thousands of radians) that precision is too coarse to tell a
// whole turn from a nearly whole arc, the sweep is kept as written.
let sweep = end.map_or(tau, |end| {
let precision = (start.abs() + end.abs() + tau) * f64::from(f32::EPSILON);
let sweep = end - start;
let whole = precision <= WHOLE_TURN_SLACK && sweep.abs() >= tau - precision;
if whole { tau.copysign(sweep) } else { sweep }
});
NodeKind::Ellipse {
start: start_within_a_turn(start),
sweep: sweep.clamp(-tau, tau),
inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0),
}
}
// Point counts aren't part of the documented JSON: the outline is exact when present.
"REGULAR_POLYGON" | "STAR" if point_count().is_none() && has_geometry => NodeKind::Vector { network: VectorNetwork::default() },
@@ -1509,3 +1521,22 @@ pub fn missing_image_placeholder() -> Option<&'static [u8]> {
pub fn is_missing_image(data: &ImageData) -> bool {
missing_image_placeholder().is_some_and(|png| data.bytes.as_slice() == png)
}
/// The most a sweep may fall short of a turn and still be read as a whole one (radians, about
/// 0.06°): far more than 32-bit angles round by within a few hundred turns of zero, and far less
/// than any arc anyone draws.
const WHOLE_TURN_SLACK: f64 = 1e-3;
/// An ellipse's start brought within a turn, drawn the same: a start already within one is kept
/// as it is, one past a turn comes to [0, 2π). It is reduced the way the renderer's sine and
/// cosine see it, exactly, rather than by dividing by a rounded 2π, which drifts a little with
/// every turn.
pub(crate) fn start_within_a_turn(start: f64) -> f64 {
let tau = std::f64::consts::TAU;
if !start.is_finite() || start.abs() < tau {
return start;
}
// `rem_euclid` rounds a hair below zero up to 2π itself.
let within = start.sin().atan2(start.cos()).rem_euclid(tau);
if within < tau { within } else { 0.0 }
}
+41
View File
@@ -756,3 +756,44 @@ fn the_fig_import_sets_its_own_layer_cap() {
assert_eq!(doc.node_count(), 5);
assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}");
}
/// Angles written many turns round keep the arc they draw: the sweep is the difference as
/// written, and the start comes within a turn of zero, the same way round.
#[test]
fn an_arc_written_many_turns_round_keeps_its_sweep() {
let tau = std::f64::consts::TAU;
for (start, end) in [
(999.0, 999.0 + tau),
(1500.0, 1501.0),
(-5000.0, -5000.0 - 2.5),
(1e6, 1e6 + tau),
(1e6, 1e6 + 5.9),
(1e6, 1e6 + 6.1),
(3e7, 3e7),
(-4e5, -4e5 + tau - 0.01),
(-182.212373908208, -182.212373908208 + tau),
] {
let mut arc = json!({"id": "Arc", "name": "Arc", "type": "ELLIPSE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}});
arc["arcData"] = json!({"startingAngle": start, "endingAngle": end, "innerRadius": 0});
let (doc, _) = one(json!([arc]));
let NodeKind::Ellipse { start: s, sweep, .. } = named(&doc, "Arc").kind else { panic!("{start}: not an ellipse") };
let want = end - start;
let want = if (want.abs() - tau).abs() < 1e-6 { tau.copysign(want) } else { want };
assert!((sweep - want).abs() < 1e-9, "{start}..{end}: sweep {sweep}, not {want}");
assert!(s.abs() < tau, "{start}: start {s} left past a turn");
let off = (s - start).rem_euclid(tau);
assert!(off.min(tau - off) < 1e-6, "{start}: start {s} isn't the same way round");
}
}
/// An arc with a start but no end goes all the way round, however far out the start is.
#[test]
fn an_arc_without_an_end_is_whole_wherever_it_starts() {
for start in [0.0, 2.0, -5000.0, 1e8, 1e15] {
let mut arc = json!({"id": "Arc", "name": "Arc", "type": "ELLIPSE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}});
arc["arcData"] = json!({"startingAngle": start, "innerRadius": 0});
let (doc, _) = one(json!([arc]));
let NodeKind::Ellipse { sweep, .. } = named(&doc, "Arc").kind else { panic!("{start}: not an ellipse") };
assert_eq!(sweep, std::f64::consts::TAU, "start {start}");
}
}
+5
View File
@@ -226,11 +226,16 @@ impl<'b> Archive<'b> {
};
let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let mut pages = Vec::new();
let mut seen = std::collections::HashSet::new();
for r in refs.iter().take(MAX_PAGES) {
let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else {
warnings.push("pages the document lists without a valid reference were skipped".into());
continue;
};
// A page listed twice is read once: a repeated reference must not repeat the unpacking.
if !seen.insert(id) {
continue;
}
match json_member(&mut z, &format!("pages/{id}.json"), max_member, &mut total) {
Ok(page) if page.is_object() => pages.push((id.to_string(), page)),
Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()),
+7
View File
@@ -35,6 +35,13 @@ fn pages_become_pages_in_document_order() {
assert_eq!(page_names(&doc), ["Beta", "Alpha"]);
}
#[test]
fn a_page_listed_twice_is_imported_once() {
let z = sketch_zip("50.2", 103, document(&["A", "A", "A"]), &[page("A", "Alpha", vec![])], &[]);
let (doc, _) = import(&z, "f").unwrap();
assert_eq!(page_names(&doc), ["Alpha"]);
}
#[test]
fn older_than_48_is_refused() {
let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]);
+14
View File
@@ -123,3 +123,17 @@ fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
}
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_replaced_file_keeps_its_permissions() {
use std::os::unix::fs::PermissionsExt;
let d = dir("mode");
let path = d.join("a.figcraft");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
write_file_atomic(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
let _ = std::fs::remove_dir_all(&d);
}
+12
View File
@@ -629,14 +629,26 @@ fn distribute(items: &mut [Item], mut open: Vec<usize>, free: f64) {
.collect();
let violation: f64 = sized.iter().map(|(_, want, v)| v - want).sum();
let frozen = |want: f64, v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > want + 1e-9) || (violation < 0.0 && v < want - 1e-9);
let mut progressed = false;
for (i, want, v) in &sized {
if frozen(*want, *v)
&& let Some(it) = items.get_mut(*i)
{
it.main = *v;
free -= *v;
progressed = true;
}
}
if !progressed {
// Several clampings each under the tolerance added up past it, so none froze: settle
// them all as clamped rather than go round again with nothing changed.
for (i, _, v) in &sized {
if let Some(it) = items.get_mut(*i) {
it.main = *v;
}
}
break;
}
free = free.max(0.0);
open = sized.into_iter().filter(|(_, want, v)| !frozen(*want, *v)).map(|(i, _, _)| i).collect();
}
+21
View File
@@ -447,3 +447,24 @@ fn grid_hostile_values() {
assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite());
}
}
#[test]
fn fill_children_whose_tiny_clampings_add_up_past_the_tolerance_still_settle() {
// Three fill children sharing 300 px, each with a minimum 5e-10 over its 100 px share: no
// single clamping passes the 1e-9 tolerance, but together they do, so no child froze and the
// distribution loop went round forever without changing anything.
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 100.0).unwrap();
d.get_mut(f).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..Default::default() });
let kids: Vec<NodeId> = (0..3).map(|i| d.create(f, NodeKind::Rectangle, "K", 0.0, 0.0, 10.0, 20.0 + i as f64).unwrap()).collect();
for k in &kids {
let n = d.get_mut(*k).unwrap();
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.min_w = Some(100.0 + 5e-10);
}
layout_frame(&mut d, f, &NoMeasure);
for k in &kids {
assert!((pos(&d, *k).2 - 100.0).abs() < 1e-6, "{:?}", pos(&d, *k));
}
}
+44 -2
View File
@@ -128,7 +128,7 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> {
if !(args.is_object() || args.is_null()) {
return Err("`arguments` must be an object of strings".into());
}
let mut text = p.template.replace("{verify}", VERIFY);
let mut values: Vec<(&str, String)> = vec![("verify", VERIFY.to_string())];
for a in p.args {
let v = match args.get(a.name) {
Some(Value::String(s)) if !s.trim().is_empty() => s.clone(),
@@ -142,7 +142,49 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> {
Some(Value::String(_)) => p.defaults.iter().find(|(k, _)| *k == a.name).map(|(_, d)| (*d).to_string()).unwrap_or_default(),
Some(_) => return Err(format!("argument `{}` must be a string", a.name)),
};
text = text.replace(&format!("{{{}}}", a.name), &v);
values.push((a.name, v));
}
// One pass over the template, so an argument's text is never read as `{…}` syntax itself (a
// value holding placeholders used to be expanded again by every later replacement).
let mut text = String::with_capacity(p.template.len());
let mut rest = p.template;
while let Some(open) = rest.find('{') {
text.push_str(rest.get(..open).unwrap_or_default());
let after = rest.get(open..).unwrap_or_default();
let found = after.find('}').and_then(|close| {
let key = after.get(1..close)?;
values.iter().find(|(k, _)| *k == key).map(|(_, v)| (close, v.as_str()))
});
match found {
Some((close, v)) => {
text.push_str(v);
rest = after.get(close + 1..).unwrap_or_default();
}
None => {
text.push('{');
rest = after.get(1..).unwrap_or_default();
}
}
}
text.push_str(rest);
Ok(json!({"description": p.description, "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arguments_are_text_not_template_syntax() {
let text = |args: Value| get("mobile_screen", &args).unwrap()["messages"][0]["content"]["text"].as_str().unwrap().to_string();
let plain = text(json!({"screen": "login"}));
assert!(plain.contains("Design a login screen") && plain.contains("iPhone 16") && plain.ends_with(VERIFY), "{plain}");
assert!(!plain.contains("{screen}") && !plain.contains("{verify}"), "{plain}");
// A value made of placeholders stays as typed, however many there are: it is never
// expanded again by the replacements after it (that multiplied the output without bound).
let bomb = "{device}{brand_color}{verify}".repeat(1000);
let out = text(json!({"screen": bomb.clone()}));
assert!(out.contains(&bomb));
assert!(out.len() < 3 * bomb.len(), "{}", out.len());
}
}
+3 -1
View File
@@ -131,7 +131,9 @@ pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) -
let p = preset(&brush.preset)?;
let unit = shape(p.id)?;
let lines = polylines(path)?;
let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t));
// Sanitized once: at most 64 points, so each sample's symmetry scan stays cheap.
let profile = props.profile().map(|pr| pr.sanitized());
let scale = |t: f64| width * profile.as_ref().map_or(1.0, |pr| pr.scale_at(t));
let mut out = BezPath::new();
match p.kind {
BrushKind::Stretch => {
+11
View File
@@ -72,3 +72,14 @@ fn a_width_profile_scales_the_brush() {
let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box());
assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}");
}
#[test]
fn an_unsanitized_profile_is_bounded_before_sampling() {
// 100k points, all past the 64-point cap and out of range: the brush reads the clamped profile.
let raw = WidthProfile { points: (0..100_000).map(|i| figcraft_doc::WidthPoint::new(f64::from(i), 1e300)).collect() };
let b = brush("taper");
let mut p = props(1.0, &b);
p.width_profile = Some(raw);
let out = outline(&line(50.0), 1.0, &p, &b).unwrap();
assert!(out.bounding_box().height() <= 2.0 * WidthProfile::MAX_SCALE);
}
+13 -3
View File
@@ -1,7 +1,7 @@
//! Text: real, selectable text in embedded (subset) fonts, positioned glyph by glyph from the
//! `figcraft_text` layout the renderer uses, with outline fallbacks.
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
use std::sync::Arc;
@@ -56,14 +56,24 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
let styles: BTreeSet<usize> = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect();
// Every run's fills, then the inner shadows over them, then the strokes on top.
let mut stroked = Vec::new();
// Glyphs and decorations are grouped by style in one pass; each style's paths are built (and
// dropped) inside its own iteration, so only one style's paths are held at a time.
let mut decoration_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (i, d) in layout.decorations.iter().enumerate() {
decoration_of.entry(d.style).or_default().push(i);
}
let mut glyph_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (i, g) in layout.glyphs.iter().enumerate() {
glyph_of.entry(g.style).or_default().push(i);
}
for k in styles {
let fills = layout.styles.get(k).and_then(|st| st.fills.as_ref()).unwrap_or(&n.fills);
let mut decorations = BezPath::new();
for d in layout.decorations.iter().filter(|d| d.style == k) {
for d in decoration_of.remove(&k).unwrap_or_default().into_iter().filter_map(|i| layout.decorations.get(i)) {
decorations.extend(d.path());
}
let mut outlines = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.style == k) {
for g in glyph_of.remove(&k).unwrap_or_default().into_iter().filter_map(|i| layout.glyphs.get(i)) {
outlines.extend(g.path());
}
outlines.extend(decorations.iter());
+5
View File
@@ -1031,6 +1031,11 @@ fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf:
/// Mask `m` drawn as if it weren't a mask into a `w` × `h` pixmap at `xf` (a vector mask: its
/// outline, fully opaque).
fn mask_pixels(ctx: &Ctx, m: &Node, (w, h): (u32, u32), xf: Affine, depth: usize) -> Option<Pixmap> {
// A mask's content is drawn here, not through `draw_layer`, so a chain of masks inside
// masks needs the nesting limit checked here too, or it runs down the stack.
if depth > crate::MAX_EXPORT_DEPTH {
return None;
}
let mut mask_px = Pixmap::new(w, h)?;
let mut m2 = m.clone();
m2.is_mask = false;
+27
View File
@@ -78,6 +78,20 @@ fn decoding_is_capped() {
assert!(images::decode_frames(&huge).is_none());
}
#[test]
fn a_large_partial_frame_plays_when_two_whole_frames_fit() {
// Canvas 4000 x 3000 (48 MB a frame, two fit the cap) with a 3500 x 2800 second frame: the
// decoder's canvas, composited output and partial frame together need more than the cap.
let mut out = Vec::new();
{
let mut enc = GifEncoder::new(&mut out);
enc.set_repeat(Repeat::Infinite).unwrap();
let frame = |w, h, c| Frame::from_parts(RgbaImage::from_pixel(w, h, image::Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
enc.encode_frames([frame(4000, 3000, RED), frame(3500, 2800, GREEN)]).unwrap();
}
assert_eq!(images::decode_frames(&out).map(|f| f.len()), Some(2));
}
#[test]
fn the_first_frame_draws_unless_time_moves_on() {
let mut d = Document::new("t");
@@ -98,3 +112,16 @@ fn the_first_frame_draws_unless_time_moves_on() {
d.get_mut(id).unwrap().visible = false;
assert!(images::animation_clock(&d, page, 0.15).is_none(), "hidden layers don't keep the presenter awake");
}
#[test]
fn a_frame_descriptor_bigger_than_the_canvas_is_refused_before_it_is_allocated() {
// A 4 × 4 canvas whose first frame descriptor claims 16,383 × 16,383 (a gigabyte of RGBA):
// the decoder's limits refuse the frame buffer instead of allocating it.
let mut bytes = gif(&[(RED, 100), (GREEN, 100)], 4, 4);
let at = bytes.windows(9).position(|w| w == [0x2c, 0, 0, 0, 0, 4, 0, 4, 0]).expect("an image descriptor for a 4 × 4 frame");
let (w, h) = ((16_383u16).to_le_bytes(), (16_383u16).to_le_bytes());
bytes[at + 5..at + 9].copy_from_slice(&[w[0], w[1], h[0], h[1]]);
let start = std::time::Instant::now();
assert!(images::decode_frames(&bytes).is_none());
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
}
+10 -1
View File
@@ -293,12 +293,21 @@ pub fn decode_frames(bytes: &[u8]) -> Option<Vec<(Pixmap, f64)>> {
if image::guess_format(bytes).ok()? != image::ImageFormat::Gif {
return None;
}
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
let mut dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
let (w, h) = dec.dimensions();
let frame_bytes = u64::from(w) * u64::from(h) * 4;
if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE {
return None;
}
// A frame's own descriptor can ask for a far larger buffer than the canvas: the decoder
// reserves each frame and canvas buffer against these limits before allocating it.
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_SIDE);
limits.max_image_height = Some(MAX_SIDE);
// The decoder charges its canvas, its composited output and the partial frame against this
// together, besides the frames kept (capped by `most` below): one frame of working room.
limits.max_alloc = Some(MAX_ANIMATION_BYTES.saturating_add(frame_bytes));
dec.set_limits(limits).ok()?;
let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize;
let mut out = Vec::new();
for f in dec.into_frames().take(most) {
+21
View File
@@ -139,3 +139,24 @@ fn a_deep_nest_of_isolated_layers_stays_within_the_budget() {
// The outer levels still draw (the innermost, past the budget, are left out).
assert_ne!(&pix.data()[..4], &[255, 255, 255, 255]);
}
#[test]
fn masks_nested_inside_masks_stop_at_the_nesting_limit() {
// A mask's content is drawn straight from `mask_pixels`, not through `draw_layer`, so a mask
// group inside a mask group inside… used to recurse past the limit and down the stack.
let mut d = Document::new("deep masks");
let p = d.first_page().unwrap();
let mut parent = p;
for _ in 0..2000 {
let g = d.create(parent, NodeKind::Group, "Masking group", 0.0, 0.0, 1.0, 1.0).unwrap();
let n = d.get_mut(g).unwrap();
n.is_mask = true;
n.mask_type = MaskType::Alpha;
parent = g;
}
shape(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 100.0, 100.0), "2040E0");
shape(&mut d, p, NodeKind::Rectangle, (0.0, 0.0, 100.0, 100.0), "E04020");
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 50, height: 50 };
let pix = render(&d, p, &v, false);
assert_eq!(pix.width(), 50);
}
+3 -1
View File
@@ -79,11 +79,13 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
const N: u32 = 256;
let mut p = Pixmap::new(N, N)?;
let half = f64::from(N) / 2.0;
// Sorted once for the 65 536 samples below, not once per sample.
let stops = g.sorted_stops();
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
let x = ((i as u32 % N) as f64 + 0.5 - half) / half;
let y = ((i as u32 / N) as f64 + 0.5 - half) / half;
let t = (x.abs() + y.abs()) as f32;
let c = g.sample(t);
let c = figcraft_color::sample_sorted(&stops, t);
let a = c.a * opacity;
let q = |v: f32| (v * a * 255.0).round().clamp(0.0, 255.0) as u8;
px.copy_from_slice(&[q(c.r), q(c.g), q(c.b), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
+25 -2
View File
@@ -103,11 +103,34 @@ pub(crate) struct Ctx<'a> {
pattern_depth: usize,
/// Tiles drawn so far, by pattern and scale (see [`paint`]'s `render_tile`).
tiles: HashMap<(*const usvg::Pattern, u64), Option<paint::Tile>>,
/// The document image key of each embedded image's bytes already added, so an image a
/// pattern paints on many shapes is hashed once, not once per shape.
image_keys: HashMap<*const Vec<u8>, String>,
}
impl<'a> Ctx<'a> {
pub(crate) fn new(doc: &'a mut Document) -> Self {
Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0, tiles: HashMap::new() }
Ctx {
doc,
count: 0,
anonymous: HashSet::new(),
notes: BTreeSet::new(),
fonts: None,
pattern_depth: 0,
tiles: HashMap::new(),
image_keys: HashMap::new(),
}
}
/// [`add_image`] for `bytes`, once per distinct buffer.
pub(crate) fn image(&mut self, bytes: std::sync::Arc<Vec<u8>>, w: f64, h: f64) -> String {
if let Some(k) = self.image_keys.get(&std::sync::Arc::as_ptr(&bytes)) {
return k.clone();
}
let ptr = std::sync::Arc::as_ptr(&bytes);
let key = add_image(self.doc, bytes, w, h);
self.image_keys.insert(ptr, key.clone());
key
}
pub(crate) fn full(&self) -> bool {
@@ -605,7 +628,7 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine
let size = img.size();
let (iw, ih) = (f64::from(size.width()), f64::from(size.height()));
let (transform, w, h) = place(world, Point::ZERO, iw, ih).unwrap_or((world, iw, ih));
let hash = add_image(cx.doc, bytes, iw, ih);
let hash = cx.image(bytes, iw, ih);
let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h);
n.transform = transform;
let mut fill = Paint::image(hash, ImageScaleMode::Fill);
+14 -1
View File
@@ -44,6 +44,11 @@ fn inflate(data: &[u8], limit: usize) -> Result<Vec<u8>, String> {
if out.len() > limit {
return Err(format!("compressed SVG expands past {} MiB", limit >> 20));
}
// usvg gunzips what it is handed, with no limit: one layer is unpacked here, within the
// budget, and what comes out must be the document itself.
if gzipped(&out) {
return Err("compressed SVG holds another compressed file, not a document".into());
}
Ok(out)
}
@@ -72,7 +77,15 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option
return None;
}
let left = limits.embedded.saturating_sub(used.bytes.load(Ordering::Relaxed));
let plain = inflate(data, left).ok()?;
let plain = match inflate(data, left) {
Ok(plain) => plain,
// What a refused document unpacked before it was refused was work too: it uses up what is
// left, so the next image can't repeat it.
Err(_) => {
used.bytes.fetch_add(left, Ordering::Relaxed);
return None;
}
};
used.bytes.fetch_add(plain.len(), Ordering::Relaxed);
if super::too_deep(&plain) {
return None;
+28
View File
@@ -111,3 +111,31 @@ fn linked_files_and_other_mime_types_stay_unresolved() {
);
assert_eq!(layers(data.as_bytes(), Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
}
#[test]
fn a_document_compressed_twice_is_refused_not_unpacked_again() {
// One gzip layer is unpacked within the budget; usvg would unpack a second one without any.
let twice = gzip(&gzip(svg(&" ".repeat(1 << 20)).as_bytes()));
let e = layers(&twice, Limits { document: 4 << 20, ..Limits::default() }).unwrap_err();
assert!(e.contains("another compressed file"), "{e}");
// Embedded, the same is dropped.
let inner = svg(r##"<rect width="10" height="10"/>"##);
let data = svg(&format!(
r##"<image width="10" height="10" xlink:href="data:image/svg+xml;base64,{}"/>"##,
base64::engine::general_purpose::STANDARD.encode(gzip(&gzip(inner.as_bytes())))
))
.into_bytes();
assert_eq!(layers(&data, Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
}
#[test]
fn a_refused_embedded_bomb_uses_up_the_budget_for_the_images_after_it() {
let bomb = gzip(svg(&" ".repeat(1 << 20)).as_bytes());
let inner = svg(r##"<rect width="10" height="10"/>"##);
let image = |href: String| format!(r##"<image width="10" height="10" xlink:href="{href}"/>"##);
let bomb_uri = format!("data:image/svg+xml;base64,{}", base64::engine::general_purpose::STANDARD.encode(&bomb));
let data = svg(&format!("{}{}", image(bomb_uri).repeat(8), image(uri(inner.as_bytes())))).into_bytes();
let limits = Limits { embedded: 4096, ..Limits::default() };
let empty = layers(svg("").as_bytes(), limits).unwrap();
assert_eq!(layers(&data, limits).unwrap(), empty, "the first bomb spends the allowance; nothing after it unpacks");
}
+1 -1
View File
@@ -133,7 +133,7 @@ fn pattern(cx: &mut Ctx, pat: &usvg::Pattern, t: Target) -> Option<Paint> {
let s = img.size();
let (iw, ih) = (f64::from(s.width()), f64::from(s.height()));
let b = figcraft_geom::transform_rect_bbox(m, Rect::new(0.0, 0.0, iw, ih));
(tile_rect.contains(b.origin()) && tile_rect.contains(Point::new(b.x1, b.y1))).then(|| (add_image(cx.doc, bytes, iw, ih), iw, ih, m))
(tile_rect.contains(b.origin()) && tile_rect.contains(Point::new(b.x1, b.y1))).then(|| (cx.image(bytes, iw, ih), iw, ih, m))
});
let (hash, iw, ih, content) = match lone {
Some(l) => l,
+3 -2
View File
@@ -153,8 +153,9 @@ fn an_image_stretched_into_its_box_stays_stretched() {
#[test]
fn linked_files_are_never_read() {
let dir = std::env::temp_dir().join("figcraft-svg-linked");
std::fs::create_dir_all(&dir).unwrap();
let dir = std::env::temp_dir().join(format!("figcraft-svg-linked-{}", std::process::id()));
// `create_dir` refuses a directory or link someone else planted at this name.
std::fs::create_dir(&dir).unwrap();
let png = dir.join("secret.png");
std::fs::write(&png, base64::Engine::decode(&base64::engine::general_purpose::STANDARD, PNG).unwrap()).unwrap();
let href = png.to_string_lossy().replace('\\', "/");
+4 -1
View File
@@ -139,7 +139,10 @@ fn macos_font_assets_are_found_in_the_asset_store() {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id())));
// Created fresh: a directory or link already at this predictable name is refused, and not removed.
let root = std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id()));
std::fs::create_dir(&root).unwrap();
let tmp = TempDir(root);
let store = tmp.0.join("AssetsV2");
let fonts = store.join("com_apple_MobileAsset_Font7");
let data = |kind: &std::path::Path| kind.join("0f1e2d.asset").join("AssetData");
+241 -10
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use figcraft_doc::{
DecorationLength, DecorationStyle, LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData,
TextDecoration, TextStyle, color::PaintKind, valid_feature_tag,
TextDecoration, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag,
};
use figcraft_geom::{Affine, BezPath, Rect};
use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
@@ -630,7 +630,8 @@ impl SavedStarts<'_> {
}
}
/// The lines (item range, paragraph end, paragraph), wrapped by measure or at `saved` line starts;
/// The lines (item range, paragraph end, paragraph), wrapped to `wrap_of` (the width for each line
/// of each paragraph; None: no wrapping) or at `saved` line starts;
/// None when a saved line would overflow its box by more than [`SAVED_LINE_SLACK`]. Lines break
/// where [`crate::breaks::break_between`] allows, and between the words `word_breaks` marks.
fn break_lines(
@@ -638,7 +639,7 @@ fn break_lines(
chars: &[char],
cluster_start: &[bool],
word_breaks: &[bool],
wrap: Option<f64>,
wrap_of: &WidthOf<'_>,
line_indent_of: &impl Fn(usize, bool, usize) -> f64,
rtl_para_at: &impl Fn(usize) -> bool,
hang_start: bool,
@@ -652,6 +653,7 @@ fn break_lines(
let mut last_break: Option<usize> = None;
let mut para_first_line = true;
let mut para = 0;
let mut line_in_para = 0;
while i < items.len() {
let Some(it) = items.get(i) else { break };
if it.ch == '\n' {
@@ -662,6 +664,7 @@ fn break_lines(
last_break = None;
para_first_line = true;
para += 1;
line_in_para = 0;
continue;
}
// A line separator (a soft break) breaks the line inside its paragraph: no
@@ -673,6 +676,7 @@ fn break_lines(
x = 0.0;
last_break = None;
para_first_line = false;
line_in_para += 1;
continue;
}
let line_indent = line_indent_of(para, para_first_line, it.char_start);
@@ -681,7 +685,7 @@ fn break_lines(
let ltr = !rtl_para_at(it.char_start);
let hung = items.get(line_start).filter(|f| hang_start && ltr && opens(f.ch)).map_or(0.0, |f| f.adv)
+ if hang_end && ltr && closes(it.ch) { w } else { 0.0 };
if let Some(maxw) = wrap
if let Some(maxw) = wrap_of(para, line_in_para)
&& i > line_start
&& !it.is_space()
{
@@ -702,6 +706,7 @@ fn break_lines(
x = 0.0;
last_break = None;
para_first_line = false;
line_in_para += 1;
continue;
}
}
@@ -721,6 +726,220 @@ fn break_lines(
Some(lines)
}
/// A line as [`break_lines`] gives it: item range, paragraph end, paragraph.
type LineRange = (usize, usize, bool, usize);
/// The width a line wraps to (its paragraph, the line's index in it); None: no wrapping.
type WidthOf<'a> = dyn Fn(usize, usize) -> Option<f64> + 'a;
/// [`break_lines`] with the widths and the saved starts to follow, the rest bound.
type Breaker<'a> = dyn Fn(&WidthOf<'_>, Option<SavedStarts<'_>>) -> Option<Vec<LineRange>> + 'a;
/// What a paragraph's lines wrap to under Balance or Pretty: the paragraph's width, and a
/// narrower one for a single line (the line Pretty has give a word away).
#[derive(Clone, Copy)]
struct ParaWidth {
width: f64,
line: Option<(usize, f64)>,
}
/// Whether the line's text is one word: no break opportunity between its items, its trailing
/// spaces and the paragraph's line break aside.
fn single_word(items: &[Item], word_breaks: &[bool], (start, end, _, _): LineRange) -> bool {
let Some(last) = (start..end).rev().find(|&k| items.get(k).is_some_and(|it| !it.is_space() && !is_line_break(it.ch))) else {
return false;
};
!(start..last).any(|k| {
let (Some(a), Some(b)) = (items.get(k), items.get(k + 1)) else { return false };
crate::breaks::break_between(a.ch, b.ch) || (a.char_end <= b.char_start && word_breaks.get(b.char_start).copied().unwrap_or(false))
})
}
/// How many of `lines` end inside a word: emergency breaks, made where no word boundary fitted
/// the width. Balance and Pretty never add one (a word that fits the box stays whole).
fn emergency_breaks(items: &[Item], word_breaks: &[bool], lines: &[LineRange]) -> usize {
lines
.iter()
.filter(|&&(_, end, para_end, _)| {
if para_end {
return false;
}
let (Some(a), Some(b)) = (end.checked_sub(1).and_then(|k| items.get(k)), items.get(end)) else { return false };
!(a.is_space()
|| is_line_break(a.ch)
|| crate::breaks::break_between(a.ch, b.ch)
|| (a.char_end <= b.char_start && word_breaks.get(b.char_start).copied().unwrap_or(false)))
})
.count()
}
/// What a line's hanging punctuation adds to the room [`break_lines`] gives it (its opening mark
/// at the start, its closing one at the end), so Pretty narrows a line by its text as the breaker
/// measures it.
fn hung_width(items: &[Item], (s, e, _, _): LineRange, hang_start: bool, hang_end: bool, rtl_para_at: &dyn Fn(usize) -> bool) -> f64 {
let ltr = items.get(s).is_none_or(|it| !rtl_para_at(it.char_start));
let first = items.get(s).filter(|it| hang_start && ltr && opens(it.ch)).map_or(0.0, |it| it.adv);
let last = e.checked_sub(1).filter(|k| *k > s).and_then(|k| items.get(k)).filter(|it| hang_end && ltr && closes(it.ch)).map_or(0.0, |it| it.adv);
first + last
}
/// The width of the line's text, its trailing spaces and line break aside, as the breaker
/// measures it.
fn line_width(items: &[Item], (start, end, _, _): LineRange) -> f64 {
let mut x = 0.0;
let mut width = 0.0;
for it in items.get(start..end).unwrap_or(&[]) {
if is_line_break(it.ch) {
break;
}
x += it.adv;
if !it.is_space() {
width = x - it.spacing;
}
}
width
}
/// Balance and Pretty: the greedy `lines` of a box `w` wide, re-broken paragraph by paragraph.
/// Balance narrows a paragraph as far as it goes without another line, so its lines come out
/// close to the same length ("distributes text as evenly as possible across every line, so each
/// line is close to the same length"; what browsers do for `text-wrap: balance`). Pretty leaves
/// lines full but, when the last line is one word, narrows one of the last four lines just enough
/// to give a word to the lines after it ("keeps each line full, but adjusts the last four lines to
/// avoid an orphan"). `rebreak` lays the text out again with a width per paragraph and line.
fn restyle_breaks(
style: WrapStyle,
w: f64,
lines: Vec<LineRange>,
items: &[Item],
word_breaks: &[bool],
line_indent_of: &dyn Fn(usize, bool, usize) -> f64,
hung_of: &dyn Fn(LineRange) -> f64,
rebreak: &dyn Fn(&WidthOf<'_>) -> Vec<LineRange>,
) -> Vec<LineRange> {
let paragraphs = lines.last().map_or(0, |l| l.3 + 1);
// Every pass lays the whole text out again: a very long text keeps its greedy lines.
if items.len().saturating_mul(paragraphs) > 2_000_000 {
return lines;
}
let mut plan = vec![ParaWidth { width: w, line: None }; paragraphs];
// The lines with paragraph `p` wrapped to `pw` and the others as planned.
let with = |plan: &[ParaWidth], p: usize, pw: ParaWidth| {
let width_of = move |q: usize, li: usize| {
let pw = if q == p { pw } else { plan.get(q).copied().unwrap_or(ParaWidth { width: w, line: None }) };
Some(match pw.line {
Some((j, narrow)) if li == j => narrow,
_ => pw.width,
})
};
rebreak(&width_of)
};
let of = |ls: &[LineRange], p: usize| -> Vec<LineRange> { ls.iter().copied().filter(|l| l.3 == p).collect() };
let mut current = lines;
for p in 0..paragraphs {
let para = of(&current, p);
let n = para.len();
if n < 2 {
continue;
}
// Words wider than the box break where they must; the re-breaking adds no such break.
let base = emergency_breaks(items, word_breaks, &para);
match style {
WrapStyle::Auto => {}
WrapStyle::Balance => {
// The narrowest width that still gives `n` lines, to half a pixel.
let (mut lo, mut hi) = (0.0, w);
while hi - lo > 0.5 {
let mid = (lo + hi) / 2.0;
let ls = of(&with(&plan, p, ParaWidth { width: mid, line: None }), p);
if ls.len() == n && emergency_breaks(items, word_breaks, &ls) <= base {
hi = mid;
} else {
lo = mid;
}
}
let pw = ParaWidth { width: hi, line: None };
current = with(&plan, p, pw);
if let Some(slot) = plan.get_mut(p) {
*slot = pw;
}
}
WrapStyle::Pretty => {
if !para.last().is_some_and(|l| single_word(items, word_breaks, *l)) {
continue;
}
// The line before the last, back to the fourth from the end: narrowed to just under
// its text, its last word goes to the lines after it (which stay full); a word or
// two more if the last line is still alone. A line that would rather start another
// line can't give a word away.
'lines: for j in (n.saturating_sub(4)..n - 1).rev() {
let Some(&line) = para.get(j) else { continue };
let indent = line_indent_of(p, j == 0, items.get(line.0).map_or(0, |it| it.char_start));
let mut narrow = line_width(items, line) - hung_of(line);
for _ in 0..3 {
narrow -= 0.5;
if narrow <= 0.0 {
break;
}
let pw = ParaWidth { width: w, line: Some((j, narrow + indent)) };
let ls = with(&plan, p, pw);
let after = of(&ls, p);
if after.len() != n || emergency_breaks(items, word_breaks, &after) > base {
break;
}
if !after.last().is_some_and(|l| single_word(items, word_breaks, *l)) {
current = ls;
if let Some(slot) = plan.get_mut(p) {
*slot = pw;
}
break 'lines;
}
narrow = after.get(j).map_or(0.0, |l| line_width(items, *l) - hung_of(*l));
}
}
}
}
}
current
}
/// Pins a breaking closure to the signature [`wrapped_lines`] takes: a closure whose parameter
/// borrows a trait object is otherwise inferred for one lifetime, not any.
fn breaker<F>(f: F) -> F
where
F: Fn(&WidthOf<'_>, Option<SavedStarts<'_>>) -> Option<Vec<LineRange>>,
{
f
}
/// The lines of a text wrapped to `wrap`: the file's saved breaks while they hold, else greedy
/// ones, re-broken for Balance or Pretty. `breaks` runs [`break_lines`] with a width per line and
/// the saved starts to follow.
fn wrapped_lines(
text: &TextData,
wrap: Option<f64>,
items: &[Item],
word_breaks: &[bool],
line_indent_of: &dyn Fn(usize, bool, usize) -> f64,
hung_of: &dyn Fn(LineRange) -> f64,
breaks: &Breaker<'_>,
) -> Vec<LineRange> {
// Where the file saved its line breaks, they win while the text and box are as saved.
let saved = wrap.and_then(|w| text.saved_breaks_for(w)).map(|starts| SavedStarts { starts, next: 0 });
let from_saved = saved.and_then(|s| breaks(&|_, _| wrap, Some(s)));
let followed = from_saved.is_some();
let lines = from_saved.or_else(|| breaks(&|_, _| wrap, None)).unwrap_or_default();
// Balance and Pretty re-break the paragraphs at narrower widths (never saved breaks).
match wrap {
Some(w) if !followed && text.wrap_style != WrapStyle::Auto => {
restyle_breaks(text.wrap_style, w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
breaks(wrap_of, None).unwrap_or_default()
})
}
_ => lines,
}
}
#[derive(Clone, Copy)]
struct LayoutFinish<'a> {
text: &'a TextData,
@@ -970,10 +1189,11 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
TextAutoResize::WidthAndHeight => None,
_ => box_width.filter(|w| w.is_finite() && *w > 0.0),
};
let breaks = |saved| break_lines(&items, &chars, &cluster_start, &word_breaks, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved);
// Where the file saved its line breaks, they win while the text and box are as saved.
let saved = wrap.and_then(|w| text.saved_breaks_for(w)).map(|starts| SavedStarts { starts, next: 0 });
let lines = saved.and_then(|s| breaks(Some(s))).or_else(|| breaks(None)).unwrap_or_default();
let breaks = breaker(|wrap_of, saved| {
break_lines(&items, &chars, &cluster_start, &word_breaks, wrap_of, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved)
});
let hung_of = |l: LineRange| hung_width(&items, l, hang_start, hang_end, &rtl_para_at);
let lines = wrapped_lines(text, wrap, &items, &word_breaks, &line_indent_of, &hung_of, &breaks);
// Line metrics and positions.
let default_style = styles.first().cloned().unwrap_or_default();
let default_face = faces.first().cloned().flatten();
@@ -1478,11 +1698,22 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
}
let (script_scale, script_shift) = synth.map_or((1.0, 0.0), |(_, up)| if up { face.superscript } else { face.subscript });
let n = raw.len();
// Cluster end: the next larger cluster value, else the segment end. A glyph in the same
// cluster as the next one shares its end, so many glyphs of one cluster aren't rescanned.
let mut cends = vec![0usize; n];
for gi in (0..n).rev() {
let Some(&(_, cl, ..)) = raw.get(gi) else { continue };
let cl = cl as usize;
let shared = raw.get(gi + 1).filter(|r| r.1 as usize == cl).and_then(|_| cends.get(gi + 1).copied());
let cend = shared.unwrap_or_else(|| raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1));
if let Some(slot) = cends.get_mut(gi) {
*slot = cend;
}
}
for gi in 0..n {
let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue };
let cl = cl as usize;
// Cluster end: the next larger cluster value, else the segment end.
let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1);
let cend = cends.get(gi).copied().unwrap_or(cl + 1);
let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl);
let ch = chars.get(cl).copied().unwrap_or(' ');
let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 } * script_scale;
+6
View File
@@ -406,6 +406,12 @@ name = "English"
"Decoration style" = "Decoration style"
"Decoration" = "Decoration"
"Deep select" = "Deep select"
"Wrap style" = "Wrap style"
"Balance" = "Balance"
"Pretty" = "Pretty"
"Auto: as many words as fit on each line" = "Auto: as many words as fit on each line"
"Balance: every line close to the same length" = "Balance: every line close to the same length"
"Pretty: full lines, no one word alone on the last" = "Pretty: full lines, no one word alone on the last"
"Set hug contents" = "Set hug contents"
"Set fill container" = "Set fill container"
"Double-click an edge of an auto layout frame" = "Double-click an edge of an auto layout frame"
+5 -2
View File
@@ -299,10 +299,13 @@ fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &To
let (r, cell) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click());
let cell = cell.on_hover_text(tr(b.label()));
a11y::menu_item(&cell, tr(b.label()), "", Some(b == shape));
if b == shape || cell.hovered() {
let lit = b == shape || cell.hovered();
if lit {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
paint_choice(ui.painter(), r.shrink(6.0), &Mark::Shape(b), t.menu_text);
// The menu is the theme's popup (white in the light theme): the theme's icon
// colour, white only on the green highlight.
paint_choice(ui.painter(), r.shrink(6.0), &Mark::Shape(b), if lit { t.menu_text } else { t.icon });
if cell.clicked() {
app.run("board.setStyle", json!({"shape": b.id()}));
ui.close();
+1 -1
View File
@@ -138,7 +138,7 @@ pub(crate) fn save_file(app: &mut FigApp, data: &Value) {
let shown = name.clone();
app.pick_save(tr("Save file"), &name, &exts, move |app, path| {
let file = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or(shown);
match std::fs::write(&path, &bytes) {
match figcraft_engine::file_access::write(&path, &bytes) {
Ok(()) => app.toast(trf("Saved {name}", &[("name", &file)])),
Err(_) => app.toast(tr("Couldn't save the file")),
}
+106 -2
View File
@@ -1,8 +1,15 @@
//! Panel dropdowns (the prototype device picker, Preferences, blend modes…) draw on the theme's
//! popup fill, so their rows' text must read on it in both themes (#972): drawn headless.
//! Panel dropdowns (the prototype device picker, Preferences, blend modes…), a file tab's menu and
//! the board bar's shape menu draw on the theme's popup fill, so what is in them must read on it
//! in both themes (#972): drawn headless.
use egui::accesskit::Node;
use egui::epaint::ColorMode;
use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2};
use figcraft_doc::BoardShape;
use figcraft_engine::Session;
use serde_json::json;
use crate::present_tests::Ui;
use crate::right::{menu_check, menu_item, menu_popup};
use crate::theme::{self, Tokens};
@@ -82,3 +89,100 @@ fn dropdown_rows_read_on_the_popup_in_both_themes() {
}
}
}
/// The whole app headless on `s` in the `dark` or light theme, with windows of their own allowed
/// (a tab offers Open in new window only then); the theme's tokens.
fn app(mut s: Session, dark: bool) -> (Ui, Tokens) {
s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap();
let mut u = Ui::new(s);
u.ctx.set_embed_viewports(false);
u.frame(vec![]);
let t = Tokens::get(&u.ctx);
assert_eq!(t.dark, dark, "the app took the theme");
(u, t)
}
fn click(u: &mut Ui, at: Pos2, button: PointerButton) {
for pressed in [true, false] {
u.frame(vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }]);
}
}
/// The pointer away from the open menu until its fade-in is over: that frame's output.
fn settle(u: &mut Ui) -> egui::FullOutput {
for _ in 0..30 {
u.frame(vec![Event::PointerMoved(pos2(1430.0, 600.0))]);
}
u.frame_output(vec![])
}
/// The AccessKit nodes on screen.
fn nodes(u: &mut Ui) -> Vec<Node> {
u.ctx.enable_accesskit();
u.frame(vec![]);
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
}
fn bounds(n: &Node) -> Rect {
let b = n.bounds().unwrap();
Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))
}
/// The colours of the lines painted inside `r`.
fn lines_in(out: &egui::FullOutput, r: Rect) -> Vec<Color32> {
fn walk(s: &Shape, r: Rect, out: &mut Vec<Color32>) {
match s {
Shape::Vec(v) => v.iter().for_each(|s| walk(s, r, out)),
Shape::Path(p) if r.contains_rect(s.visual_bounding_rect()) => {
if let ColorMode::Solid(c) = p.stroke.color {
out.push(c);
}
}
_ => {}
}
}
let mut lines = Vec::new();
for c in &out.shapes {
walk(&c.shape, r, &mut lines);
}
lines
}
#[test]
fn a_file_tabs_menu_reads_on_its_popup_in_both_themes() {
for dark in [false, true] {
let (mut u, t) = app(Session::new(), dark);
click(&mut u, pos2(100.0, 20.0), PointerButton::Secondary);
let texts = menu_texts(&settle(&mut u));
let Some((_, col)) = texts.iter().find(|(s, _)| s == "Open in new window") else { panic!("the tab's menu opened (dark {dark}): {texts:?}") };
let ratio = color(*col).contrast(color(t.panel));
assert!(ratio >= 4.5, "Open in new window at {ratio:.2}:1 on the popup (dark {dark})");
}
}
#[test]
fn the_board_bars_shape_menu_shows_every_shape_in_both_themes() {
for dark in [false, true] {
let mut s = Session::new();
s.execute("file.newBoard", &json!({})).unwrap();
s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0})).unwrap();
let (mut u, t) = app(s, dark);
let all = nodes(&mut u);
let button = all.iter().find(|n| n.label() == Some("Shape")).expect("the board bar's Shape button");
click(&mut u, bounds(button).center(), PointerButton::Primary);
let all = nodes(&mut u);
let out = settle(&mut u);
// Every shape but the picked one (white on the green highlight) on the popup itself.
for b in BoardShape::ALL.into_iter().filter(|b| *b != BoardShape::Rectangle) {
let label = crate::i18n::tr(b.label());
let cell = all.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("{label} in the menu (dark {dark})"));
let lines = lines_in(&out, bounds(cell));
assert!(!lines.is_empty(), "{label}'s outline painted (dark {dark})");
for c in lines {
// Non-text contrast (WCAG 1.4.11): at least 3:1.
let ratio = color(c).contrast(color(t.panel));
assert!(ratio >= 3.0, "{label}'s outline at {ratio:.2}:1 on the popup (dark {dark})");
}
}
}
}
@@ -385,6 +385,18 @@ fn type_settings(
if let Some(i) = text_seg(ui, "trim", &items, trim, w) {
app.set_props(ids, json!({"leadingTrim": if i == 1 { "capHeight" } else { "none" }}));
}
// Where lines break when the text wraps (Explore text properties).
caption(ui, tr("Wrap style"));
let wrap = common(doc, ids, |n| n.kind.text().map(|t| t.wrap_style as usize)).flatten();
let items = [
(tr("Auto"), tr_noop("Auto: as many words as fit on each line")),
(tr("Balance"), tr_noop("Balance: every line close to the same length")),
(tr("Pretty"), tr_noop("Pretty: full lines, no one word alone on the last")),
];
if let Some(i) = text_seg(ui, "wrap", &items, wrap, w) {
let style = ["auto", "balance", "pretty"].get(i).copied().unwrap_or("auto");
app.set_props(ids, json!({"wrapStyle": style}));
}
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
// Auto-height text with a line limit truncates too.
let truncating = common(doc, ids, |n| n.kind.text().map(TextData::truncates)).flatten();
+4 -3
View File
@@ -162,9 +162,10 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
None => {}
}
if several {
resp.context_menu(|ui| {
ui.set_width(200.0);
if crate::menus::item(ui, tr("Open in new window"), "", true, false, &t) {
// The tab strip's own menu takes the theme like its overflow menus (the in-app
// menus stay dark, #843): its rows read on the white popup of the light theme.
crate::right::context_popup(&resp).show(|ui| {
if crate::right::menu_item(ui, tr("Open in new window"), "") {
detach = Some((i, None));
ui.close();
}
+39 -7
View File
@@ -24,6 +24,8 @@ pub(crate) struct Walker<'a> {
opts: &'a VdOptions,
warn: Warnings,
names: std::collections::BTreeSet<String>,
/// The next suffix to try for each base name (names only get taken, so none before it is free).
next_suffix: std::collections::BTreeMap<String, usize>,
}
/// Every coordinate of `p` finite and within [`MAX_COORD`].
@@ -54,7 +56,7 @@ fn normal(b: BlendMode) -> bool {
impl<'a> Walker<'a> {
pub fn new(doc: &'a Document, opts: &'a VdOptions) -> Self {
Self { doc, opts, warn: Warnings::default(), names: Default::default() }
Self { doc, opts, warn: Warnings::default(), names: Default::default(), next_suffix: Default::default() }
}
/// Reports that the export drew nothing at all (named after `layer`).
@@ -78,12 +80,25 @@ impl<'a> Walker<'a> {
fn unique(&mut self, name: &str) -> String {
let base: String = name.chars().take(200).collect();
let base = if base.trim().is_empty() { "layer".to_string() } else { base };
// Bounded: a set of n names leaves one of the first n + 1 candidates free.
let tries = self.names.len().saturating_add(1);
let name = (1..=tries)
.map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") })
.find(|n| !self.names.contains(n))
.unwrap_or_else(|| format!("{base}_{}", tries.saturating_add(1)));
let mut k = self.next_suffix.get(&base).copied().unwrap_or(1);
let mut stem = base.clone();
// Bounded: a set of n names leaves one of the first n + 1 candidates free. A counter
// that has run out (`usize::MAX`) continues from a longer stem instead of repeating.
let name = loop {
let candidate = if k == 1 { stem.clone() } else { format!("{stem}_{k}") };
if !self.names.contains(&candidate) {
k = k.saturating_add(1);
break candidate;
}
if k == usize::MAX {
stem = candidate;
k = 2;
} else {
k += 1;
}
};
let longer = stem.len() > base.len();
self.next_suffix.insert(base, if longer { usize::MAX } else { k });
self.names.insert(name.clone());
name
}
@@ -404,3 +419,20 @@ impl<'a> Walker<'a> {
}
}
}
#[cfg(test)]
mod unique_tests {
use super::*;
#[test]
fn an_exhausted_suffix_counter_still_finds_a_free_name() {
let (doc, opts) = (Document::new("File"), VdOptions::default());
let mut w = Walker::new(&doc, &opts);
w.next_suffix.insert("a".into(), usize::MAX);
w.names.insert(format!("a_{}", usize::MAX));
let first = w.unique("a");
let second = w.unique("a");
assert_ne!(first, second);
assert!(w.names.len() == 3 && w.names.contains(&first) && w.names.contains(&second));
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter afteLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testingLine truncated
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -86,10 +86,11 @@ interaction feel not yet compared.
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875). Not yet: areas of other layers edited at once |
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) |
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
| Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` |
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) |
| Comment (C), Shift+C to show/hide | done | local only |
@@ -177,6 +178,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. |
| 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |
| 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` |
| 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` |
| 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. |